@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.
Files changed (84) 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/rules-javascript.mjs +16 -3
  23. package/src/detect/sast/rules-python.mjs +8 -6
  24. package/src/detect/sast/scanner.mjs +1 -1
  25. package/src/detect/signals/agent-frameworks.mjs +231 -0
  26. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  27. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  28. package/src/detect/signals/agentic-shim.mjs +82 -0
  29. package/src/detect/signals/artifacts.mjs +8 -36
  30. package/src/detect/signals/autonomy.mjs +9 -1
  31. package/src/detect/signals/chat-template.mjs +211 -0
  32. package/src/detect/signals/ci-workflow.mjs +169 -0
  33. package/src/detect/signals/credential-harvest.mjs +1 -1
  34. package/src/detect/signals/execution-hijack.mjs +4 -2
  35. package/src/detect/signals/gate.mjs +84 -11
  36. package/src/detect/signals/guardrail-shape.mjs +564 -0
  37. package/src/detect/signals/guardrail-surface.mjs +221 -0
  38. package/src/detect/signals/injection.mjs +8 -0
  39. package/src/detect/signals/inspect-shim.mjs +7 -0
  40. package/src/detect/signals/instruction-paths.mjs +60 -0
  41. package/src/detect/signals/manifests.mjs +302 -0
  42. package/src/detect/signals/masking.mjs +14 -1
  43. package/src/detect/signals/mcp-advisories.mjs +109 -0
  44. package/src/detect/signals/mcp-config.mjs +598 -0
  45. package/src/detect/signals/memory-directives.mjs +661 -0
  46. package/src/detect/signals/memory-locations.mjs +158 -0
  47. package/src/detect/signals/memory.mjs +56 -31
  48. package/src/detect/signals/model-config-rules.mjs +655 -0
  49. package/src/detect/signals/model-config.mjs +61 -0
  50. package/src/detect/signals/packages.mjs +2 -2
  51. package/src/detect/signals/prose-context.mjs +6 -9
  52. package/src/detect/signals/scan.mjs +4 -4
  53. package/src/detect/signals/secret-scanner.mjs +241 -0
  54. package/src/detect/signals/secrets.mjs +1 -48
  55. package/src/detect/signals/shell.mjs +10 -10
  56. package/src/gate/advisories.mjs +16 -0
  57. package/src/gate/batch.mjs +10 -0
  58. package/src/gate/environment.mjs +8 -53
  59. package/src/guard/artifact-paths.mjs +107 -0
  60. package/src/guard/classify.mjs +165 -7
  61. package/src/guard/command-resolve.mjs +35 -5
  62. package/src/guard/memory-write.mjs +218 -0
  63. package/src/guard/prompt-guard.mjs +0 -1
  64. package/src/guard/tool-guard.mjs +52 -77
  65. package/src/inventory/agent-posture.mjs +236 -57
  66. package/src/inventory/artifacts/classify.mjs +10 -1
  67. package/src/inventory/artifacts/discover.mjs +113 -3
  68. package/src/inventory/artifacts/extensions.mjs +70 -0
  69. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  70. package/src/inventory/artifacts/limits.mjs +1 -1
  71. package/src/inventory/artifacts/plugins.mjs +105 -0
  72. package/src/inventory/artifacts/roots.mjs +40 -0
  73. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  74. package/src/inventory/discovery/all.mjs +4 -0
  75. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  76. package/src/inventory/discovery/coding-agents.mjs +19 -4
  77. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  78. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  79. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  80. package/src/inventory/env-redirect.mjs +148 -0
  81. package/src/inventory/grant-extract.mjs +463 -0
  82. package/src/inventory/project-roots.mjs +108 -0
  83. package/src/inventory/vscode-state.mjs +153 -0
  84. package/src/mcp/server-tools.mjs +1 -1
@@ -1,32 +1,10 @@
1
- import { execSync } from 'node:child_process';
1
+ import { git } from '../core/git-exec.mjs';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
- export const GATE_KINDS = ['mcp', 'skill', 'command', 'subagent', 'hook', 'rules', 'agent-card', 'memory', 'auto'];
6
-
7
- /**
8
- * ⚠ A CLOUD AGENT SESSION IS NOT A DEVELOPER MACHINE, and until this existed it
9
- * reported as one. Claude Code on the web runs in an EPHEMERAL container: fresh
10
- * $HOME, no `shomra` config, a hostname nobody will ever see again. It carries
11
- * no CI variables, so it fell through to LOCAL - and an operator counting
12
- * "screened laptops" was counting containers that no longer exist.
13
- *
14
- * ⚠ CI IS CHECKED FIRST and stays first. A cloud session driven by a GitHub
15
- * Action is CI: that branch carries repo, ref and commit, which is the stronger
16
- * attribution. REMOTE is what is left when nothing else names where this ran.
17
- */
18
- /**
19
- * ⚠ ONE ENTRY PER RUNTIME, and only where the variable has been SEEN. A marker
20
- * invented from a vendor's docs either never fires - useless - or fires on a
21
- * name something else uses, which labels a real laptop as an ephemeral
22
- * container and puts a `floor` on a machine the org actually owns. Add a row
23
- * here once somebody has read the variable out of a live session of that
24
- * runtime; until then that runtime uses SHOMRA_ENVIRONMENT below, which is the
25
- * whole reason the override exists.
26
- *
27
- * `verified` records who has actually seen it, so the next person can tell a
28
- * confirmed marker from an optimistic one.
29
- */
5
+ export const GATE_KINDS = ['mcp', 'skill', 'command', 'subagent', 'hook', 'rules', 'agent-card', 'memory', 'plugin', 'tool-manifest', 'model-config', 'extension', 'workflow', 'guardrail', 'framework', 'auto'];
6
+
7
+
30
8
  export const REMOTE_RUNTIMES = [
31
9
  {
32
10
  runner: 'claude-code-cloud',
@@ -37,13 +15,6 @@ export const REMOTE_RUNTIMES = [
37
15
  },
38
16
  ];
39
17
 
40
- /**
41
- * ⚠ CODESPACES AND DEVCONTAINERS ARE DELIBERATELY ABSENT. They are containers,
42
- * but they PERSIST between sessions, can be enrolled, and their guard survives
43
- * to report a window it spent blind - which is the whole distinction REMOTE
44
- * draws. Filing them here would put "we cannot vouch for its silence" on
45
- * machines that can in fact vouch for it.
46
- */
47
18
  export function remoteRunner(e = process.env) {
48
19
  for (const rt of REMOTE_RUNTIMES) {
49
20
  for (const key of rt.vars) if (String(e?.[key] ?? '').trim()) return rt.runner;
@@ -61,15 +32,7 @@ export function declaredEnvironment(e = process.env) {
61
32
  return v in ENV_RANK ? v : null;
62
33
  }
63
34
 
64
- /**
65
- * ⚠⚠ THE OVERRIDE MAY ONLY EVER RAISE. `SHOMRA_ENVIRONMENT` exists so an
66
- * operator can declare a runtime we do not yet detect - a cloud agent from a
67
- * vendor whose markers nobody has read. Letting it go the other way would make
68
- * it a switch that relabels a detected ephemeral container as a trusted laptop,
69
- * clearing the `floor` its unreportable silence earns. That is the same
70
- * privilege-reduction shape `mayRaiseOnly` and `foldTimeout` refuse on the
71
- * server, and it would be reachable by anything that can set an env var.
72
- */
35
+
73
36
  export function mergeEnvironment(detected, declared) {
74
37
  if (!declared) return detected;
75
38
  return ENV_RANK[declared] > ENV_RANK[detected] ? declared : detected;
@@ -147,16 +110,8 @@ export function detectEnv(env) {
147
110
  }
148
111
 
149
112
  function gitContext() {
150
- const run = (args) => {
151
- try {
152
- return execSync(`git ${args}`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 })
153
- .toString()
154
- .trim();
155
- } catch {
156
- return undefined;
157
- }
158
- };
159
- const origin = run('config --get remote.origin.url');
113
+ const run = (args) => git(args, { timeout: 2000 })?.trim();
114
+ const origin = run(['config', '--get', 'remote.origin.url']);
160
115
  let repo;
161
116
  if (origin) {
162
117
  const m = origin.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
@@ -165,7 +120,7 @@ function gitContext() {
165
120
 
166
121
  let repoUrl = origin || undefined;
167
122
  if (repoUrl) repoUrl = repoUrl.replace(/^([a-z][\w+.-]*:\/\/)[^/@]*@/i, '$1');
168
- return { repo, repoUrl, ref: run('rev-parse --abbrev-ref HEAD'), commit: run('rev-parse HEAD') };
123
+ return { repo, repoUrl, ref: run(['rev-parse', '--abbrev-ref', 'HEAD']), commit: run(['rev-parse', 'HEAD']) };
169
124
  }
170
125
 
171
126
  export function collectSiblings(fullTarget, relPath) {
@@ -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.