@shomra/agent 0.3.28 → 0.3.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/hook-command.mjs +1 -1
- package/src/artifacts/matchers.mjs +7 -0
- package/src/cli/flags.mjs +2 -2
- package/src/cli/help-sections.mjs +7 -0
- package/src/cli/help.mjs +1 -1
- package/src/commands/check.mjs +3 -11
- package/src/commands/gate.mjs +26 -4
- package/src/commands/git-hooks.mjs +2 -2
- package/src/commands/ledger.mjs +0 -1
- package/src/commands/mcp-add.mjs +2 -1
- package/src/commands/memory-scan.mjs +135 -47
- package/src/commands/pr.mjs +7 -10
- package/src/commands/provenance.mjs +8 -13
- package/src/commands/scan.mjs +7 -1
- package/src/commands/secrets.mjs +4 -5
- package/src/core/git-exec.mjs +79 -0
- package/src/core/yaml-lite.mjs +300 -0
- package/src/core/zip-lite.mjs +37 -0
- package/src/detect/local-redact.mjs +1 -3
- package/src/detect/sast/rules-config.mjs +1 -1
- package/src/detect/sast/rules-javascript.mjs +16 -3
- package/src/detect/sast/rules-python.mjs +8 -6
- package/src/detect/sast/scanner.mjs +1 -1
- package/src/detect/signals/agent-frameworks.mjs +231 -0
- package/src/detect/signals/agent-graph-surface.mjs +113 -0
- package/src/detect/signals/agentic-ci-surface.mjs +314 -0
- package/src/detect/signals/agentic-shim.mjs +82 -0
- package/src/detect/signals/artifacts.mjs +8 -36
- package/src/detect/signals/autonomy.mjs +9 -1
- package/src/detect/signals/chat-template.mjs +211 -0
- package/src/detect/signals/ci-workflow.mjs +169 -0
- package/src/detect/signals/credential-harvest.mjs +1 -1
- package/src/detect/signals/execution-hijack.mjs +4 -2
- package/src/detect/signals/gate.mjs +84 -11
- package/src/detect/signals/guardrail-shape.mjs +564 -0
- package/src/detect/signals/guardrail-surface.mjs +221 -0
- package/src/detect/signals/injection.mjs +8 -0
- package/src/detect/signals/inspect-shim.mjs +7 -0
- package/src/detect/signals/instruction-paths.mjs +60 -0
- package/src/detect/signals/manifests.mjs +302 -0
- package/src/detect/signals/masking.mjs +14 -1
- package/src/detect/signals/mcp-advisories.mjs +109 -0
- package/src/detect/signals/mcp-config.mjs +598 -0
- package/src/detect/signals/memory-directives.mjs +661 -0
- package/src/detect/signals/memory-locations.mjs +158 -0
- package/src/detect/signals/memory.mjs +56 -31
- package/src/detect/signals/model-config-rules.mjs +655 -0
- package/src/detect/signals/model-config.mjs +61 -0
- package/src/detect/signals/packages.mjs +2 -2
- package/src/detect/signals/prose-context.mjs +6 -9
- package/src/detect/signals/scan.mjs +4 -4
- package/src/detect/signals/secret-scanner.mjs +241 -0
- package/src/detect/signals/secrets.mjs +1 -48
- package/src/detect/signals/shell.mjs +10 -10
- package/src/gate/advisories.mjs +16 -0
- package/src/gate/batch.mjs +10 -0
- package/src/gate/environment.mjs +8 -53
- package/src/guard/artifact-paths.mjs +107 -0
- package/src/guard/classify.mjs +165 -7
- package/src/guard/command-resolve.mjs +35 -5
- package/src/guard/memory-write.mjs +218 -0
- package/src/guard/prompt-guard.mjs +0 -1
- package/src/guard/tool-guard.mjs +52 -77
- package/src/inventory/agent-posture.mjs +236 -57
- package/src/inventory/artifacts/classify.mjs +10 -1
- package/src/inventory/artifacts/discover.mjs +113 -3
- package/src/inventory/artifacts/extensions.mjs +70 -0
- package/src/inventory/artifacts/hook-scripts.mjs +128 -0
- package/src/inventory/artifacts/limits.mjs +1 -1
- package/src/inventory/artifacts/plugins.mjs +105 -0
- package/src/inventory/artifacts/roots.mjs +40 -0
- package/src/inventory/discovery/ai-dependencies.mjs +39 -12
- package/src/inventory/discovery/all.mjs +4 -0
- package/src/inventory/discovery/cloud-clis.mjs +472 -0
- package/src/inventory/discovery/coding-agents.mjs +19 -4
- package/src/inventory/discovery/mcp-clients.mjs +16 -10
- package/src/inventory/discovery/mcp-servers.mjs +125 -35
- package/src/inventory/discovery/mcp-stores.mjs +207 -0
- package/src/inventory/env-redirect.mjs +148 -0
- package/src/inventory/grant-extract.mjs +463 -0
- package/src/inventory/project-roots.mjs +108 -0
- package/src/inventory/vscode-state.mjs +153 -0
- package/src/mcp/server-tools.mjs +1 -1
package/src/gate/environment.mjs
CHANGED
|
@@ -1,32 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
+
|
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.
|