@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.
- 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/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 +7 -35
- package/src/detect/signals/chat-template.mjs +211 -0
- package/src/detect/signals/ci-workflow.mjs +169 -0
- package/src/detect/signals/gate.mjs +77 -9
- 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/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 +47 -29
- package/src/detect/signals/model-config-rules.mjs +655 -0
- package/src/detect/signals/model-config.mjs +61 -0
- 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 +3 -3
- 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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const ARTIFACT_PATHS = [
|
|
2
|
+
|
|
3
|
+
{ re: /(^|\/)\.?mcp\.json$/i, kind: 'mcp' },
|
|
4
|
+
{ re: /(^|\/)\.(?:vscode|cursor|roo|kilocode|trae|windsurf|kiro|amazonq|zed|gemini|qwen|junie)\/mcp\.json$/i, kind: 'mcp' },
|
|
5
|
+
{ re: /(^|\/)\.kiro\/settings\/mcp\.json$/i, kind: 'mcp' },
|
|
6
|
+
{ re: /(^|\/)claude_desktop_config\.json$/i, kind: 'mcp' },
|
|
7
|
+
{ re: /(^|\/)(?:cline_mcp_settings|mcp_settings)\.json$/i, kind: 'mcp' },
|
|
8
|
+
{ re: /(^|\/)\.codeium\/windsurf\/mcp_config\.json$/i, kind: 'mcp' },
|
|
9
|
+
|
|
10
|
+
{ re: /(^|\/)\.claude\/settings(?:\.local)?\.json$/i, kind: 'hook' },
|
|
11
|
+
{ re: /(^|\/)managed-settings\.json$/i, kind: 'hook' },
|
|
12
|
+
{ re: /(^|\/)\.(?:github|copilot)\/hooks\/[^/]+\.json$/i, kind: 'hook' },
|
|
13
|
+
{ re: /(^|\/)hooks\/hooks\.json$/i, kind: 'hook' },
|
|
14
|
+
{ re: /(^|\/)\.(?:cursor|windsurf|codeium|gemini|qwen|codex|cline|roo)\/hooks(?:\.json|\/[^/]+\.json)$/i, kind: 'hook' },
|
|
15
|
+
|
|
16
|
+
{ re: /(^|\/)SKILL\.md$/i, kind: 'skill' },
|
|
17
|
+
{ re: /(^|\/)\.(?:claude|cursor|windsurf|codex|agents)\/skills\/.+\.(?:md|markdown)$/i, kind: 'skill' },
|
|
18
|
+
|
|
19
|
+
{ re: /(^|\/)\.[\w-]+\/commands?\/[^/]*\.(?:md|markdown|toml)$/i, kind: 'command' },
|
|
20
|
+
{ re: /(^|\/)\.[\w-]+\/prompts?\/[^/]*\.(?:md|markdown|toml)$/i, kind: 'command' },
|
|
21
|
+
{ re: /(^|\/)\.(?:windsurf|devin|clinerules)\/workflows\/[^/]*\.(?:md|markdown|ya?ml)$/i, kind: 'command' },
|
|
22
|
+
|
|
23
|
+
{ re: /(^|\/)\.(?:claude|cursor|windsurf|roo|kilocode)\/agents\/[^/]+\.(?:md|ya?ml|json)$/i, kind: 'subagent' },
|
|
24
|
+
{ re: /(^|\/)\.github\/(?:agents|chatmodes)\/[^/]+\.md$/i, kind: 'subagent' },
|
|
25
|
+
|
|
26
|
+
{ re: /(^|\/)\.well-known\/agent(?:-card)?\.json$/i, kind: 'agent-card' },
|
|
27
|
+
{ re: /(^|\/)agent[-_]card\.json$/i, kind: 'agent-card' },
|
|
28
|
+
|
|
29
|
+
{ re: /(^|\/)\.claude-plugin\/(?:plugin|marketplace)\.json$/i, kind: 'plugin' },
|
|
30
|
+
{ re: /(^|\/)gemini-extension\.json$/i, kind: 'plugin' },
|
|
31
|
+
{ re: /(^|\/)(?:mcpb|dxt)\.json$/i, kind: 'extension' },
|
|
32
|
+
|
|
33
|
+
{ re: /(^|\/)(?:CLAUDE|AGENTS|AGENT|GEMINI|QWEN|KIRO|AMAZONQ|CONVENTIONS)(?:\.local|\.override)?\.md$/i, kind: 'rules' },
|
|
34
|
+
{ re: /(^|\/)\.(?:cursor|aider|continue|kilocode|roo|trae|augment|windsurf|qoder|factory|opencode)rules$/i, kind: 'rules' },
|
|
35
|
+
{ re: /(^|\/)\.roorules(?:-[\w-]+)?$/i, kind: 'rules' },
|
|
36
|
+
{ re: /(^|\/)\.goosehints$/i, kind: 'rules' },
|
|
37
|
+
{ re: /(^|\/)\.augment-guidelines$/i, kind: 'rules' },
|
|
38
|
+
{ re: /(^|\/)global_rules\.md$/i, kind: 'rules' },
|
|
39
|
+
{ re: /(^|\/)\.rules$/i, kind: 'rules' },
|
|
40
|
+
{ re: /(^|\/)\.aider\.conf\.ya?ml$/i, kind: 'rules' },
|
|
41
|
+
{ re: /(^|\/)\.github\/copilot-instructions\.md$/i, kind: 'rules' },
|
|
42
|
+
{ re: /(^|\/)\.github\/instructions\/.+\.md$/i, kind: 'rules' },
|
|
43
|
+
{ re: /(^|\/)\.copilot\/(?:copilot-instructions\.md|instructions\/.+\.md)$/i, kind: 'rules' },
|
|
44
|
+
{ re: /(^|\/)\.cursor\/rules\/.+\.(?:mdc|md)$/i, kind: 'rules' },
|
|
45
|
+
{ re: /(^|\/)\.claude\/rules\/.+\.md$/i, kind: 'rules' },
|
|
46
|
+
{ re: /(^|\/)\.clinerules(?:\/[^/]+\.(?:md|txt))?$/i, kind: 'rules' },
|
|
47
|
+
{ re: /(^|\/)cline\/rules\/.+\.(?:md|txt)$/i, kind: 'rules' },
|
|
48
|
+
{ re: /(^|\/)\.(?:kilocode|roo|trae|junie|windsurf|augment|kiro|devin|amp|zed|goose|opencode|crush)\/(?:rules?|guidelines?|steering|memories)(?:\/.+)?\.(?:md|mdc|txt)$/i, kind: 'rules' },
|
|
49
|
+
{ re: /(^|\/)\.(?:roo|kilocode)\/rules-[\w-]+\/.+\.(?:md|mdc|txt)$/i, kind: 'rules' },
|
|
50
|
+
{ re: /(^|\/)\.continue\/rules\/.+\.(?:md|ya?ml)$/i, kind: 'rules' },
|
|
51
|
+
{ re: /(^|\/)\.amazonq\/rules\/.+\.md$/i, kind: 'rules' },
|
|
52
|
+
{ re: /(^|\/)\.qwen\/team-memory\/.+\.(?:md|txt)$/i, kind: 'rules' },
|
|
53
|
+
{ re: /(^|\/)\.junie\/(?:playbook|guidelines)\.md$/i, kind: 'rules' },
|
|
54
|
+
{ re: /(^|\/)\.trae\/(?:rules\/)?(?:project|user)_rules\.md$/i, kind: 'rules' },
|
|
55
|
+
{ re: /(^|\/)\.claude\/output-styles\/.+\.md$/i, kind: 'rules' },
|
|
56
|
+
|
|
57
|
+
{ re: /(^|\/)\.codex\/config\.(?:toml|json)$/i, kind: 'agent-config' },
|
|
58
|
+
{ re: /(^|\/)\.(?:gemini|qwen)\/settings\.json$/i, kind: 'agent-config' },
|
|
59
|
+
{ re: /(^|\/)\.cursor\/(?:cli-config|cli|permissions|sandbox|settings)\.json$/i, kind: 'agent-config' },
|
|
60
|
+
{ re: /(^|\/)\.vscode\/settings\.json$/i, kind: 'agent-config' },
|
|
61
|
+
{ re: /(^|\/)\.kiro\/settings\/[^/]+\.json$/i, kind: 'agent-config' },
|
|
62
|
+
{ re: /(^|\/)\.(?:copilot|roo|kilocode|amazonq|zed|opencode|augment|junie|trae)\/(?:settings|config)(?:\.local)?\.json$/i, kind: 'agent-config' },
|
|
63
|
+
{ re: /(^|\/)\.github\/copilot\/settings(?:\.local)?\.json$/i, kind: 'agent-config' },
|
|
64
|
+
{ re: /(^|\/)opencode\.jsonc?$/i, kind: 'agent-config' },
|
|
65
|
+
{ re: /(^|\/)crush\.json$/i, kind: 'agent-config' },
|
|
66
|
+
{ re: /(^|\/)goose\/config\.ya?ml$/i, kind: 'agent-config' },
|
|
67
|
+
{ re: /(^|\/)\.continue\/config\.(?:json|ya?ml)$/i, kind: 'agent-config' },
|
|
68
|
+
|
|
69
|
+
{ re: /(^|\/)(?:\.devcontainer\/)?devcontainer\.json$/i, kind: 'dev-environment' },
|
|
70
|
+
{ re: /(^|\/)\.devcontainer\/[^/]+\/devcontainer\.json$/i, kind: 'dev-environment' },
|
|
71
|
+
|
|
72
|
+
{ re: /(^|\/)[\w.-]*modelfile$/i, kind: 'model-config' },
|
|
73
|
+
{ re: /(^|\/)(?:tokenizer_config|generation_config)\.json$/i, kind: 'model-config' },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export const CONTENT_ONLY_KINDS = [
|
|
77
|
+
'automation', 'guardrail', 'tool-manifest', 'prompt-template',
|
|
78
|
+
'agent-memory', 'exec-script', 'env-secret-file', 'other-artifact',
|
|
79
|
+
|
|
80
|
+
'workflow',
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export function normalizeArtifactPath(path ) {
|
|
84
|
+
const raw = String(path ?? '').replace(/\\/g, '/').trim();
|
|
85
|
+
if (!raw) return '';
|
|
86
|
+
const lead = raw.startsWith('/') ? '/' : '';
|
|
87
|
+
const drive = /^[a-zA-Z]:\//.test(raw) ? raw.slice(0, 3) : '';
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const seg of (drive ? raw.slice(3) : raw).split('/')) {
|
|
90
|
+
if (!seg || seg === '.') continue;
|
|
91
|
+
if (seg === '..') {
|
|
92
|
+
if (out.length && out[out.length - 1] !== '..') out.pop();
|
|
93
|
+
else out.push('..');
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
out.push(seg);
|
|
97
|
+
}
|
|
98
|
+
return (drive || lead) + out.join('/');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function artifactKindFor(path ) {
|
|
102
|
+
const p = normalizeArtifactPath(path);
|
|
103
|
+
if (!p) return null;
|
|
104
|
+
for (const { re, kind } of ARTIFACT_PATHS) if (re.test(p)) return kind;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
package/src/guard/classify.mjs
CHANGED
|
@@ -1,15 +1,74 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { artifactKindFor } from './artifact-paths.mjs';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* WHAT COUNTS AS A WRITE, AND IT WAS TEN NAMES SHORT.
|
|
6
|
+
*
|
|
7
|
+
* This set decides whether a tool call is treated as a file write at all - here
|
|
8
|
+
* and, mirrored, on the server. It knew Claude Code's names and Cline's, and
|
|
9
|
+
* missed the ones every other vendor uses: `write_file` and `replace` (Gemini
|
|
10
|
+
* CLI, Qwen), `apply_patch` (Codex), `edit_file` (Cursor, Windsurf),
|
|
11
|
+
* `apply_diff` / `search_and_replace` / `insert_content` (Roo, Kilo). A Gemini
|
|
12
|
+
* CLI write of `.claude/settings.json` was not a write to this code - it fell
|
|
13
|
+
* through to the generic tool-call path with no target, no content and no
|
|
14
|
+
* artifact scan.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ The repo ALREADY knew these names: `MODEL_WRITE_TOOLS` below lists
|
|
17
|
+
* `apply_patch` and `write_file`, and `memory-write.mjs` special-cases
|
|
18
|
+
* `apply_patch` by hand. Two lists in one file disagreed about what a write is,
|
|
19
|
+
* which is how this survived.
|
|
20
|
+
*/
|
|
21
|
+
export const WRITE_TOOLS = new Set([
|
|
22
|
+
'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'create_file', 'str_replace_editor',
|
|
23
|
+
'str_replace_based_edit_tool', 'write_to_file', 'replace_in_file', 'new_rule',
|
|
4
24
|
|
|
5
|
-
|
|
25
|
+
'write_file', 'replace', 'apply_patch', 'apply_diff', 'edit_file', 'edit_notebook',
|
|
26
|
+
'search_and_replace', 'insert_content', 'create_text_file', 'str_replace',
|
|
27
|
+
]);
|
|
6
28
|
|
|
7
|
-
const SHELL_TOOLS_RE = /^(bash|shell|sh|run_command|run_terminal_cmd|execute_command|terminal|exec)$/i;
|
|
29
|
+
const SHELL_TOOLS_RE = /^(bash|shell|sh|run_command|run_terminal_cmd|execute_command|terminal|exec|run_shell_command)$/i;
|
|
8
30
|
|
|
9
31
|
const EGRESS_TOOL_RE = /fetch|web|http|browser|request|download|curl|url|open/i;
|
|
10
32
|
|
|
11
33
|
const EGRESS_CMD_RE = /\b(curl|wget|nc|ncat|http|https|invoke-restmethod|invoke-webrequest|irm|iwr|scp|rsync|ftp|telnet)\b/i;
|
|
12
34
|
|
|
35
|
+
/**
|
|
36
|
+
* ⚠ THE PATHS A SHELL COMMAND WRITES, so a redirect cannot walk around the
|
|
37
|
+
* tool-name check. `echo '{"permissions":{"defaultMode":"bypassPermissions"}}' >
|
|
38
|
+
* .claude/settings.json` is the same act as a Write and was not sent to the
|
|
39
|
+
* gate at all, because a shell command only reached the server when it looked
|
|
40
|
+
* like egress or an MCP install.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately coarse - it decides whether to ASK the server, and the server
|
|
43
|
+
* decides what the write is. A false positive here costs one guard call.
|
|
44
|
+
*/
|
|
45
|
+
const REDIRECT_RE = /(?:>>?|\btee\b(?:\s+-\S+)*)\s*("[^"]+"|'[^']+'|[^\s;|&()<>]+)/g;
|
|
46
|
+
const COPY_VERB_RE = /\b(?:cp|mv|install|rsync|ln|sed|perl|python3?|tee|curl|wget)\b/;
|
|
47
|
+
|
|
48
|
+
export function shellWritePaths(cmd) {
|
|
49
|
+
const out = [];
|
|
50
|
+
const text = String(cmd ?? '');
|
|
51
|
+
for (const m of text.matchAll(REDIRECT_RE)) {
|
|
52
|
+
const target = m[1].replace(/^["']|["']$/g, '');
|
|
53
|
+
if (!target || target.startsWith('&') || target.startsWith('/dev/')) continue;
|
|
54
|
+
out.push(target);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ⚠ ARGUMENT POSITIONS TOO, not only redirects. `cp /tmp/evil.mdc
|
|
59
|
+
* .cursor/rules/style.mdc` and `sed -i s/x/y/ .claude/settings.json` never
|
|
60
|
+
* pass through `>`; the destination is just an argument.
|
|
61
|
+
*/
|
|
62
|
+
if (COPY_VERB_RE.test(text)) {
|
|
63
|
+
for (const tok of text.split(/[\s;|&()]+/)) {
|
|
64
|
+
const t = tok.replace(/^["']|["']$/g, '');
|
|
65
|
+
if (!t || t.startsWith('-')) continue;
|
|
66
|
+
if (t.includes('/') || /^[\w.-]+\.(?:json|jsonc|toml|ya?ml|md|mdc)$/i.test(t)) out.push(t);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...new Set(out)].slice(0, 40);
|
|
70
|
+
}
|
|
71
|
+
|
|
13
72
|
export function guardText(tool, input) {
|
|
14
73
|
const parts = [];
|
|
15
74
|
if (typeof input.command === 'string') parts.push(input.command);
|
|
@@ -18,22 +77,121 @@ export function guardText(tool, input) {
|
|
|
18
77
|
if (typeof input.content === 'string') parts.push(input.content);
|
|
19
78
|
if (typeof input.new_string === 'string') parts.push(input.new_string);
|
|
20
79
|
if (typeof input.new_source === 'string') parts.push(input.new_source);
|
|
21
|
-
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* ⚠ THE KEYS OF THE TOOLS ALREADY ON THE WRITE LIST. `str_replace_editor` -
|
|
83
|
+
* the Anthropic text-editor shape - carries `file_text` on create and
|
|
84
|
+
* `new_str` on replace/insert; `replace_in_file` and `apply_diff` carry
|
|
85
|
+
* `diff`; `apply_patch` carries `patch`. None were read, so the payload of
|
|
86
|
+
* those writes was screened as `JSON.stringify(input)` locally and reached
|
|
87
|
+
* the server as an empty string.
|
|
88
|
+
*/
|
|
89
|
+
if (typeof input.file_text === 'string') parts.push(input.file_text);
|
|
90
|
+
if (typeof input.new_str === 'string') parts.push(input.new_str);
|
|
91
|
+
if (typeof input.diff === 'string') parts.push(input.diff);
|
|
92
|
+
if (typeof input.patch === 'string') parts.push(input.patch);
|
|
93
|
+
if (typeof input.code_edit === 'string') parts.push(input.code_edit);
|
|
94
|
+
if (Array.isArray(input.edits)) parts.push(input.edits.map((e) => e?.new_string ?? e?.new_str ?? '').join('\n'));
|
|
22
95
|
if (!parts.length) { try { parts.push(JSON.stringify(input)); } catch { parts.push(String(input)); } }
|
|
23
96
|
return parts.join('\n');
|
|
24
97
|
}
|
|
25
98
|
|
|
26
99
|
export function guardTargetPath(norm) {
|
|
27
100
|
const i = norm.tool_input || {};
|
|
28
|
-
const p = i.file_path ?? i.path ?? i.notebook_path ?? i.filename ?? null;
|
|
101
|
+
const p = i.file_path ?? i.path ?? i.notebook_path ?? i.filename ?? i.target_file ?? i.file ?? null;
|
|
29
102
|
return typeof p === 'string' && p.trim() ? p : null;
|
|
30
103
|
}
|
|
31
104
|
|
|
105
|
+
/**
|
|
106
|
+
* ⚠ EVERY PATH A CALL TOUCHES, for the send decision only. A batched read
|
|
107
|
+
* (`read_many_files` takes `paths`) and a multi-file patch both name several
|
|
108
|
+
* files, and asking about the first one is asking about the wrong one.
|
|
109
|
+
*/
|
|
110
|
+
export function guardTouchedPaths(tool, input = {}) {
|
|
111
|
+
const out = [];
|
|
112
|
+
const push = (v) => { if (typeof v === 'string' && v.trim()) out.push(v.trim()); };
|
|
113
|
+
push(input.file_path); push(input.path); push(input.notebook_path); push(input.filename);
|
|
114
|
+
push(input.target_file); push(input.file); push(input.destination); push(input.dest);
|
|
115
|
+
for (const key of ['paths', 'files', 'file_paths', 'targets']) {
|
|
116
|
+
if (Array.isArray(input[key])) for (const v of input[key]) push(typeof v === 'string' ? v : v?.path);
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(input.edits)) for (const e of input.edits) push(e?.file_path ?? e?.path);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ⚠ A PATCH ENVELOPE NAMES ITS OWN FILES. Codex's `apply_patch` carries no
|
|
122
|
+
* path argument at all - the paths are inside the patch text, so a write to
|
|
123
|
+
* `.claude/settings.json` through it looked pathless.
|
|
124
|
+
*/
|
|
125
|
+
const patch = typeof input.patch === 'string' ? input.patch : typeof input.diff === 'string' ? input.diff : '';
|
|
126
|
+
if (patch) {
|
|
127
|
+
for (const m of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm)) push(m[1]);
|
|
128
|
+
for (const m of patch.matchAll(/^(?:\+\+\+|---)\s+(?:[ab]\/)?(\S+)/gm)) push(m[1]);
|
|
129
|
+
}
|
|
130
|
+
if (typeof input.command === 'string' || typeof input.cmd === 'string') {
|
|
131
|
+
for (const p of shellWritePaths(input.command ?? input.cmd)) push(p);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* ⚠ A SYMLINK IS THE PATH IT POINTS AT. `ln -s .claude/settings.json notes.md`
|
|
136
|
+
* then a write to `notes.md` lands on the settings file while matching no
|
|
137
|
+
* rule - path-based binding cannot see through a link, and only the machine
|
|
138
|
+
* can resolve one. Resolved here, where the filesystem is, and BOTH names are
|
|
139
|
+
* kept: the one written and the one actually changed.
|
|
140
|
+
*/
|
|
141
|
+
const resolved = [];
|
|
142
|
+
for (const p of out.slice(0, 20)) {
|
|
143
|
+
try {
|
|
144
|
+
const real = fs.realpathSync(p).replace(/\\/g, '/');
|
|
145
|
+
if (real && real !== p) resolved.push(real);
|
|
146
|
+
} catch {
|
|
147
|
+
/* a path that does not exist yet cannot be a link to somewhere else */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return [...new Set([...out, ...resolved])].slice(0, 60);
|
|
151
|
+
}
|
|
152
|
+
|
|
32
153
|
export function guardNeedsServer(tool, input, hasIdentity) {
|
|
33
154
|
if (hasIdentity) return true;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* THE DECISION THIS FUNCTION MAKES IS WHETHER THE GATE EVER SEES THE
|
|
158
|
+
* CALL, and it used to answer it against a private fifteen-path regex while
|
|
159
|
+
* the server's list held sixty. A write to `.vscode/settings.json`
|
|
160
|
+
* (CVE-2025-53773, wormable, and an agent writing it IS the exploit),
|
|
161
|
+
* `.cursor/rules/*.mdc` (the rules-file backdoor) or `.codex/config.toml` was
|
|
162
|
+
* decided locally and never sent - so widening the server changed nothing
|
|
163
|
+
* here. `artifact-paths.mjs` is GENERATED from the server's own list now, so
|
|
164
|
+
* the two cannot drift again.
|
|
165
|
+
*/
|
|
166
|
+
for (const p of guardTouchedPaths(tool, input)) if (artifactKindFor(p)) return true;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* ⚠ THIS SENDS ARTIFACT *READS* TOO, AND THAT IS DELIBERATE - DO NOT NARROW
|
|
170
|
+
* IT. It costs one guard round-trip per artifact read (a handful per session:
|
|
171
|
+
* CLAUDE.md at startup, a rules file when the agent consults one), and two
|
|
172
|
+
* things depend on it that are not obvious from here:
|
|
173
|
+
*
|
|
174
|
+
* 1 A refused artifact binds ON READ. The registry decision takes effect
|
|
175
|
+
* when the content reaches the model, which is a step no install-time
|
|
176
|
+
* check can reach.
|
|
177
|
+
* 2 A READ IS HALF OF THE ONLY CROSS-SESSION PROPAGATION EDGE THE PRODUCT
|
|
178
|
+
* HAS. `HandoffService` pairs write -> read on the same resource; an
|
|
179
|
+
* instruction artifact is the canonical carrier between sessions, so a
|
|
180
|
+
* suppressed read means a poisoned CLAUDE.md written by one session and
|
|
181
|
+
* read by another produces NO edge, and the blast-radius walk reports a
|
|
182
|
+
* blind spot instead of the path.
|
|
183
|
+
*
|
|
184
|
+
* ⚠ AND A PER-SESSION DEDUPE IS WORSE THAN EITHER CHOICE, which is the trap
|
|
185
|
+
* to avoid: that pairing needs `read.at > write.at`, so reporting only the
|
|
186
|
+
* FIRST read of a file means a later poisoning write followed by a re-read
|
|
187
|
+
* pairs against nothing. It would quietly break the detection it was added to
|
|
188
|
+
* protect. The latency is bounded by the circuit breaker instead - a slow
|
|
189
|
+
* gate degrades to local screening rather than holding the agent.
|
|
190
|
+
*/
|
|
191
|
+
|
|
34
192
|
if (WRITE_TOOLS.has(tool)) {
|
|
35
193
|
const target = String(input.file_path ?? input.path ?? input.notebook_path ?? '').replace(/\\/g, '/');
|
|
36
|
-
|
|
194
|
+
if (artifactKindFor(target)) return true;
|
|
37
195
|
}
|
|
38
196
|
if (tool && tool.startsWith('mcp__')) return true;
|
|
39
197
|
if (EGRESS_TOOL_RE.test(tool || '')) return true;
|
|
@@ -53,15 +53,45 @@ function isExecutable(file, statSync) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* PATH as the shell running the command splits it.
|
|
58
|
+
*
|
|
59
|
+
* ⚠ The commands screened here are shell text - bash, or Git Bash on Windows -
|
|
60
|
+
* where `PATH=/tmp/bin:$PATH` is colon-separated. Splitting on the HOST's
|
|
61
|
+
* delimiter read that on Windows as one directory named `/tmp/bin:/usr/bin`, so
|
|
62
|
+
* the shadowing binary the prefix planted was never found and the report named
|
|
63
|
+
* nothing. A `$PATH` substituted from a Windows environment adds `;` and drive
|
|
64
|
+
* letters, so: split on `;`, and on `:` except the one after a drive letter.
|
|
65
|
+
*/
|
|
66
|
+
function pathEntries(value) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let cur = '';
|
|
69
|
+
for (const ch of String(value ?? '')) {
|
|
70
|
+
if (ch === ';' || (ch === ':' && !/^[A-Za-z]$/.test(cur))) {
|
|
71
|
+
if (cur) out.push(cur);
|
|
72
|
+
cur = '';
|
|
73
|
+
} else {
|
|
74
|
+
cur += ch;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (cur) out.push(cur);
|
|
78
|
+
return out.slice(0, MAX_PATH_ENTRIES);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const windowsStyle = (p) => /^[A-Za-z]:[\\/]/.test(p) || p.includes('\\');
|
|
82
|
+
const flavour = (p) => (windowsStyle(p) ? path.win32 : path.posix);
|
|
83
|
+
|
|
56
84
|
function locate(word, effectivePath, cwd, statSync) {
|
|
57
85
|
if (!word) return null;
|
|
86
|
+
const base = cwd || '.';
|
|
58
87
|
if (word.includes('/') || word.includes('\\')) {
|
|
59
|
-
const abs =
|
|
88
|
+
const abs = flavour(word).isAbsolute(word) ? word : flavour(base).join(base, word);
|
|
60
89
|
return isExecutable(abs, statSync) ? abs : null;
|
|
61
90
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const
|
|
91
|
+
for (const dir of pathEntries(effectivePath)) {
|
|
92
|
+
// A relative entry (`.`, `bin`) is relative to where the COMMAND runs - which is how it shadows.
|
|
93
|
+
const at = flavour(dir).isAbsolute(dir) ? dir : flavour(base).join(base, dir);
|
|
94
|
+
const candidate = flavour(at).join(at, word);
|
|
65
95
|
if (isExecutable(candidate, statSync)) return candidate;
|
|
66
96
|
}
|
|
67
97
|
return null;
|
|
@@ -72,7 +102,7 @@ function locate(word, effectivePath, cwd, statSync) {
|
|
|
72
102
|
* the filesystem this machine actually has - the two things the server does not
|
|
73
103
|
* hold and no pattern can recover from the string.
|
|
74
104
|
*
|
|
75
|
-
*
|
|
105
|
+
* IT NEVER EXECUTES ANYTHING. `$(…)` and backticks are REPORTED unresolved,
|
|
76
106
|
* never evaluated: running attacker-influenced text in order to screen it is the
|
|
77
107
|
* vulnerability, not the control. Same reason the scanner resolves an MCP source
|
|
78
108
|
* without launching it.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { CONFIG_DIR } from '../core/config.mjs';
|
|
6
|
+
|
|
7
|
+
const SECTION_TOOLS = [
|
|
8
|
+
{ re: /^save_memory$/i, file: () => path.join(os.homedir(), '.gemini', 'GEMINI.md'), heading: '## Gemini Added Memories' },
|
|
9
|
+
{ re: /^qwen[_-]?save_memory$/i, file: () => path.join(os.homedir(), '.qwen', 'QWEN.md'), heading: '## Qwen Added Memories' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const readOr = (read, p) => {
|
|
13
|
+
try {
|
|
14
|
+
return read(p);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const readUtf8 = (p) => fs.readFileSync(p, 'utf8');
|
|
21
|
+
|
|
22
|
+
function replaceOnce(text, oldStr, newStr, all) {
|
|
23
|
+
if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
|
|
24
|
+
if (oldStr === '') return text === '' ? newStr : null;
|
|
25
|
+
if (!text.includes(oldStr)) return null;
|
|
26
|
+
return all ? text.split(oldStr).join(newStr) : text.replace(oldStr, () => newStr);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function applySearchReplace(text, diff) {
|
|
30
|
+
const blocks = [...String(diff).matchAll(/(?:-{3,} SEARCH|<{3,} SEARCH)\r?\n([\s\S]*?)\r?\n={3,}\r?\n([\s\S]*?)\r?\n(?:\+{3,} REPLACE|>{3,} REPLACE)/g)];
|
|
31
|
+
if (!blocks.length) return null;
|
|
32
|
+
let out = text;
|
|
33
|
+
for (const [, search, replace] of blocks) {
|
|
34
|
+
const next = replaceOnce(out, search, replace, false);
|
|
35
|
+
if (next == null) return null;
|
|
36
|
+
out = next;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function insertAt(text, line, insert) {
|
|
42
|
+
if (typeof insert !== 'string') return null;
|
|
43
|
+
const lines = text.split('\n');
|
|
44
|
+
const at = Math.max(0, Math.min(Number(line) || 0, lines.length));
|
|
45
|
+
lines.splice(at, 0, ...insert.split('\n'));
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parsePatch(patch) {
|
|
50
|
+
const files = [];
|
|
51
|
+
let cur = null;
|
|
52
|
+
for (const raw of String(patch).split(/\r?\n/)) {
|
|
53
|
+
const add = /^\*\*\* Add File: (.+)$/.exec(raw);
|
|
54
|
+
const upd = /^\*\*\* Update File: (.+)$/.exec(raw);
|
|
55
|
+
const del = /^\*\*\* Delete File: (.+)$/.exec(raw);
|
|
56
|
+
if (add || upd || del) {
|
|
57
|
+
cur = { path: (add || upd || del)[1].trim(), op: add ? 'add' : upd ? 'update' : 'delete', hunks: [], lines: [] };
|
|
58
|
+
files.push(cur);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!cur || /^\*\*\* (Begin|End) Patch/.test(raw)) continue;
|
|
62
|
+
if (cur.op === 'add') {
|
|
63
|
+
if (raw.startsWith('+')) cur.lines.push(raw.slice(1));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (raw.startsWith('@@')) {
|
|
67
|
+
cur.hunks.push({ before: [], after: [] });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!cur.hunks.length) cur.hunks.push({ before: [], after: [] });
|
|
71
|
+
const h = cur.hunks[cur.hunks.length - 1];
|
|
72
|
+
if (raw.startsWith('-')) h.before.push(raw.slice(1));
|
|
73
|
+
else if (raw.startsWith('+')) h.after.push(raw.slice(1));
|
|
74
|
+
else {
|
|
75
|
+
const ctx = raw.startsWith(' ') ? raw.slice(1) : raw;
|
|
76
|
+
h.before.push(ctx);
|
|
77
|
+
h.after.push(ctx);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function applyHunks(text, hunks) {
|
|
84
|
+
let out = text;
|
|
85
|
+
for (const h of hunks) {
|
|
86
|
+
const before = h.before.join('\n');
|
|
87
|
+
const after = h.after.join('\n');
|
|
88
|
+
const next = before ? replaceOnce(out, before, after, false) : out + (out.endsWith('\n') || !out ? '' : '\n') + after;
|
|
89
|
+
if (next == null) return null;
|
|
90
|
+
out = next;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pathOf(input) {
|
|
96
|
+
const p = input.file_path ?? input.path ?? input.target_file ?? input.filename ?? null;
|
|
97
|
+
return typeof p === 'string' && p.trim() ? p : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveAgainst(p, cwd) {
|
|
101
|
+
if (!p) return null;
|
|
102
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
|
|
103
|
+
return path.isAbsolute(p) ? p : path.resolve(cwd || process.cwd(), p);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function memoryWritesFor(tool, input, { cwd, read = readUtf8 } = {}) {
|
|
107
|
+
const name = String(tool ?? '');
|
|
108
|
+
const i = input ?? {};
|
|
109
|
+
|
|
110
|
+
const section = SECTION_TOOLS.find((s) => s.re.test(name));
|
|
111
|
+
if (section) {
|
|
112
|
+
const fact = typeof i.fact === 'string' ? i.fact : typeof i.content === 'string' ? i.content : null;
|
|
113
|
+
if (!fact) return [];
|
|
114
|
+
const file = section.file();
|
|
115
|
+
const current = readOr(read, file) ?? '';
|
|
116
|
+
const bullet = `- ${fact.trim()}`;
|
|
117
|
+
const content = current.includes(section.heading)
|
|
118
|
+
? current.replace(section.heading, `${section.heading}\n${bullet}`)
|
|
119
|
+
: `${current}${current && !current.endsWith('\n') ? '\n' : ''}\n${section.heading}\n${bullet}\n`;
|
|
120
|
+
return [{ path: file, content, before: current, basis: 'whole' }];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (/^apply_patch$/i.test(name)) {
|
|
124
|
+
const patch = typeof i.patch === 'string' ? i.patch : typeof i.input === 'string' ? i.input : typeof i.command === 'string' ? i.command : Array.isArray(i.command) ? i.command.join('\n') : null;
|
|
125
|
+
if (!patch) return [];
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const f of parsePatch(patch)) {
|
|
128
|
+
const abs = resolveAgainst(f.path, cwd);
|
|
129
|
+
if (f.op === 'delete') continue;
|
|
130
|
+
if (f.op === 'add') {
|
|
131
|
+
out.push({ path: abs, content: f.lines.join('\n'), before: readOr(read, abs), basis: 'whole' });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const current = readOr(read, abs);
|
|
135
|
+
const next = current == null ? null : applyHunks(current, f.hunks);
|
|
136
|
+
if (next != null) out.push({ path: abs, content: next, before: current, basis: 'whole' });
|
|
137
|
+
else out.push({ path: abs, content: f.hunks.map((h) => h.after.join('\n')).join('\n'), before: current, basis: 'fragment' });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const target = resolveAgainst(pathOf(i), cwd);
|
|
143
|
+
if (!target) return [];
|
|
144
|
+
const command = typeof i.command === 'string' ? i.command : null;
|
|
145
|
+
|
|
146
|
+
if (typeof i.content === 'string' && !Array.isArray(i.edits)) return [{ path: target, content: i.content, before: readOr(read, target), basis: 'whole' }];
|
|
147
|
+
if (typeof i.file_text === 'string' && (!command || command === 'create')) return [{ path: target, content: i.file_text, before: readOr(read, target), basis: 'whole' }];
|
|
148
|
+
|
|
149
|
+
const current = readOr(read, target);
|
|
150
|
+
const whole = (content) => [{ path: target, content, before: current, basis: 'whole' }];
|
|
151
|
+
const fragment = (content) => (typeof content === 'string' && content ? [{ path: target, content, before: current, basis: 'fragment' }] : []);
|
|
152
|
+
|
|
153
|
+
if (typeof i.new_string === 'string') {
|
|
154
|
+
const next = current == null ? null : replaceOnce(current, i.old_string, i.new_string, !!i.replace_all);
|
|
155
|
+
return next != null ? whole(next) : fragment(i.new_string);
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(i.edits)) {
|
|
158
|
+
let next = current;
|
|
159
|
+
for (const e of i.edits) {
|
|
160
|
+
next = next == null ? null : replaceOnce(next, e?.old_string, e?.new_string, !!e?.replace_all);
|
|
161
|
+
}
|
|
162
|
+
return next != null ? whole(next) : fragment(i.edits.map((e) => e?.new_string ?? '').join('\n'));
|
|
163
|
+
}
|
|
164
|
+
if (command === 'str_replace' || typeof i.new_str === 'string') {
|
|
165
|
+
const next = current == null ? null : replaceOnce(current, i.old_str, i.new_str ?? '', false);
|
|
166
|
+
return next != null ? whole(next) : fragment(i.new_str);
|
|
167
|
+
}
|
|
168
|
+
if (command === 'insert') {
|
|
169
|
+
const text = i.insert_text ?? i.new_str;
|
|
170
|
+
const next = current == null ? null : insertAt(current, i.insert_line, text);
|
|
171
|
+
return next != null ? whole(next) : fragment(text);
|
|
172
|
+
}
|
|
173
|
+
if (typeof i.diff === 'string') {
|
|
174
|
+
const next = current == null ? null : applySearchReplace(current, i.diff);
|
|
175
|
+
return next != null ? whole(next) : fragment(i.diff);
|
|
176
|
+
}
|
|
177
|
+
if (typeof i.code_edit === 'string') return fragment(i.code_edit);
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const MEMORY_LEDGER_DIR = path.join(CONFIG_DIR, 'memory-ledger');
|
|
182
|
+
|
|
183
|
+
export const sha256 = (s) => crypto.createHash('sha256').update(String(s ?? ''), 'utf8').digest('hex');
|
|
184
|
+
|
|
185
|
+
function ledgerFile(p, dir) {
|
|
186
|
+
const key = crypto.createHash('sha1').update(path.resolve(p).toLowerCase()).digest('hex');
|
|
187
|
+
return path.join(dir, `${key}.json`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function readLedger(p, dir = MEMORY_LEDGER_DIR) {
|
|
191
|
+
try {
|
|
192
|
+
const row = JSON.parse(fs.readFileSync(ledgerFile(p, dir), 'utf8'));
|
|
193
|
+
return row && Array.isArray(row.hashes) ? row : null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function recordLedger(p, hashes, dir = MEMORY_LEDGER_DIR) {
|
|
200
|
+
try {
|
|
201
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
202
|
+
const prev = readLedger(p, dir);
|
|
203
|
+
const merged = [...new Set([...hashes.filter(Boolean), ...(prev?.hashes ?? [])])].slice(0, 6);
|
|
204
|
+
const file = ledgerFile(p, dir);
|
|
205
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
206
|
+
fs.writeFileSync(tmp, JSON.stringify({ path: path.resolve(p), hashes: merged, at: new Date().toISOString() }), { mode: 0o600 });
|
|
207
|
+
fs.renameSync(tmp, file);
|
|
208
|
+
} catch {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function outOfBandChange(p, currentContent, dir = MEMORY_LEDGER_DIR) {
|
|
214
|
+
if (currentContent == null) return false;
|
|
215
|
+
const row = readLedger(p, dir);
|
|
216
|
+
if (!row) return false;
|
|
217
|
+
return !row.hashes.includes(sha256(currentContent));
|
|
218
|
+
}
|