@shomra/agent 0.3.28 → 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.
- package/package.json +1 -1
- package/src/agents/hook-command.mjs +1 -1
- package/src/artifacts/matchers.mjs +7 -0
- package/src/cli/flags.mjs +2 -2
- package/src/cli/help-sections.mjs +7 -0
- package/src/cli/help.mjs +1 -1
- package/src/commands/check.mjs +3 -11
- package/src/commands/gate.mjs +26 -4
- package/src/commands/git-hooks.mjs +2 -2
- package/src/commands/ledger.mjs +0 -1
- package/src/commands/mcp-add.mjs +2 -1
- package/src/commands/memory-scan.mjs +135 -47
- package/src/commands/pr.mjs +7 -10
- package/src/commands/provenance.mjs +8 -13
- package/src/commands/scan.mjs +7 -1
- package/src/commands/secrets.mjs +4 -5
- package/src/core/git-exec.mjs +79 -0
- package/src/core/yaml-lite.mjs +300 -0
- package/src/core/zip-lite.mjs +37 -0
- package/src/detect/local-redact.mjs +1 -3
- package/src/detect/sast/rules-config.mjs +1 -1
- package/src/detect/sast/rules-javascript.mjs +16 -3
- package/src/detect/sast/rules-python.mjs +8 -6
- package/src/detect/sast/scanner.mjs +1 -1
- package/src/detect/signals/agent-frameworks.mjs +231 -0
- package/src/detect/signals/agent-graph-surface.mjs +113 -0
- package/src/detect/signals/agentic-ci-surface.mjs +314 -0
- package/src/detect/signals/agentic-shim.mjs +82 -0
- package/src/detect/signals/artifacts.mjs +8 -36
- package/src/detect/signals/autonomy.mjs +9 -1
- package/src/detect/signals/chat-template.mjs +211 -0
- package/src/detect/signals/ci-workflow.mjs +169 -0
- package/src/detect/signals/credential-harvest.mjs +1 -1
- package/src/detect/signals/execution-hijack.mjs +4 -2
- package/src/detect/signals/gate.mjs +84 -11
- package/src/detect/signals/guardrail-shape.mjs +564 -0
- package/src/detect/signals/guardrail-surface.mjs +221 -0
- package/src/detect/signals/injection.mjs +8 -0
- package/src/detect/signals/inspect-shim.mjs +7 -0
- package/src/detect/signals/instruction-paths.mjs +60 -0
- package/src/detect/signals/manifests.mjs +302 -0
- package/src/detect/signals/masking.mjs +14 -1
- package/src/detect/signals/mcp-advisories.mjs +109 -0
- package/src/detect/signals/mcp-config.mjs +598 -0
- package/src/detect/signals/memory-directives.mjs +661 -0
- package/src/detect/signals/memory-locations.mjs +158 -0
- package/src/detect/signals/memory.mjs +56 -31
- package/src/detect/signals/model-config-rules.mjs +655 -0
- package/src/detect/signals/model-config.mjs +61 -0
- package/src/detect/signals/packages.mjs +2 -2
- package/src/detect/signals/prose-context.mjs +6 -9
- package/src/detect/signals/scan.mjs +4 -4
- package/src/detect/signals/secret-scanner.mjs +241 -0
- package/src/detect/signals/secrets.mjs +1 -48
- package/src/detect/signals/shell.mjs +10 -10
- package/src/gate/advisories.mjs +16 -0
- package/src/gate/batch.mjs +10 -0
- package/src/gate/environment.mjs +8 -53
- package/src/guard/artifact-paths.mjs +107 -0
- package/src/guard/classify.mjs +165 -7
- package/src/guard/command-resolve.mjs +35 -5
- package/src/guard/memory-write.mjs +218 -0
- package/src/guard/prompt-guard.mjs +0 -1
- package/src/guard/tool-guard.mjs +52 -77
- package/src/inventory/agent-posture.mjs +236 -57
- package/src/inventory/artifacts/classify.mjs +10 -1
- package/src/inventory/artifacts/discover.mjs +113 -3
- package/src/inventory/artifacts/extensions.mjs +70 -0
- package/src/inventory/artifacts/hook-scripts.mjs +128 -0
- package/src/inventory/artifacts/limits.mjs +1 -1
- package/src/inventory/artifacts/plugins.mjs +105 -0
- package/src/inventory/artifacts/roots.mjs +40 -0
- package/src/inventory/discovery/ai-dependencies.mjs +39 -12
- package/src/inventory/discovery/all.mjs +4 -0
- package/src/inventory/discovery/cloud-clis.mjs +472 -0
- package/src/inventory/discovery/coding-agents.mjs +19 -4
- package/src/inventory/discovery/mcp-clients.mjs +16 -10
- package/src/inventory/discovery/mcp-servers.mjs +125 -35
- package/src/inventory/discovery/mcp-stores.mjs +207 -0
- package/src/inventory/env-redirect.mjs +148 -0
- package/src/inventory/grant-extract.mjs +463 -0
- package/src/inventory/project-roots.mjs +108 -0
- package/src/inventory/vscode-state.mjs +153 -0
- 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.
|
|
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
|
-
*
|
|
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]')}
|
package/src/commands/check.mjs
CHANGED
|
@@ -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
|
-
?
|
|
27
|
-
:
|
|
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
|
package/src/commands/gate.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 });
|
package/src/commands/ledger.mjs
CHANGED
package/src/commands/mcp-add.mjs
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
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
|
-
|
|
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
|
|
201
|
+
const cfg = loadConfig();
|
|
202
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
128
203
|
if (!apiKey) exitNotConfigured();
|
|
129
204
|
|
|
130
|
-
const { target, files } =
|
|
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
|
-
|
|
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 =
|
|
153
|
-
if (
|
|
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
|
-
|
|
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
|
|
package/src/commands/pr.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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) =>
|
|
47
|
+
const run = (args) => git(args, { cwd: root });
|
|
48
48
|
let out = null;
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
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
|
}
|
package/src/commands/scan.mjs
CHANGED
|
@@ -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) {
|
package/src/commands/secrets.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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 = '';
|