@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
@@ -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
+
@@ -1,15 +1,74 @@
1
- import path from 'node:path';
1
+ import fs from 'node:fs';
2
+ import { artifactKindFor } from './artifact-paths.mjs';
2
3
 
3
- const ARTIFACT_PATH_RE = /(^|\/)(\.?mcp\.json|SKILL\.md|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.cursorrules|\.windsurfrules|\.aider\.conf\.yml|agent[-_]card\.json)$|(^|\/)\.claude\/(commands|agents)\/[^/]+\.md$|(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.well-known\/agent(-card)?\.json$|(^|\/)\.clinerules|(^|\/)\.github\/copilot-instructions\.md$/i;
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
- export const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'create_file', 'str_replace_editor', 'str_replace_based_edit_tool', 'write_to_file', 'replace_in_file', 'new_rule']);
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
- if (Array.isArray(input.edits)) parts.push(input.edits.map((e) => e?.new_string ?? '').join('\n'));
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
- return ARTIFACT_PATH_RE.test(target);
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 = path.isAbsolute(word) ? word : path.resolve(cwd || '.', word);
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 entries = String(effectivePath ?? '').split(path.delimiter).filter(Boolean).slice(0, MAX_PATH_ENTRIES);
63
- for (const dir of entries) {
64
- const candidate = path.join(dir, word);
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
- * ⚠⚠ IT NEVER EXECUTES ANYTHING. `$(…)` and backticks are REPORTED unresolved,
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
+ }
@@ -170,7 +170,6 @@ function promptInjectionNote(injection) {
170
170
  );
171
171
  }
172
172
 
173
-
174
173
  function buildPromptGuardBody(norm, agent, clientDecision, clientReason) {
175
174
  const redaction = localTierDisabled()
176
175
  ? { text: norm.prompt, masked: [], changed: false }