@prestyj/cli 5.32.0 → 5.32.2
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/README.md +3 -2
- package/assets/skills/task-list/SKILL.md +39 -0
- package/dist/app-sidecar/app-settings.js +11 -5
- package/dist/app-sidecar/app-settings.js.map +1 -1
- package/dist/app-sidecar/auth-routes.js +10 -3
- package/dist/app-sidecar/auth-routes.js.map +1 -1
- package/dist/app-sidecar/git-target.d.ts +15 -2
- package/dist/app-sidecar/git-target.js +24 -10
- package/dist/app-sidecar/git-target.js.map +1 -1
- package/dist/app-sidecar/goal-routes.js +8 -0
- package/dist/app-sidecar/goal-routes.js.map +1 -1
- package/dist/app-sidecar/history-routes.js +7 -0
- package/dist/app-sidecar/history-routes.js.map +1 -1
- package/dist/app-sidecar/history.d.ts +5 -0
- package/dist/app-sidecar/history.js.map +1 -1
- package/dist/app-sidecar/integration-routes.js +4 -0
- package/dist/app-sidecar/integration-routes.js.map +1 -1
- package/dist/app-sidecar/mcp-routes.js +1 -0
- package/dist/app-sidecar/mcp-routes.js.map +1 -1
- package/dist/app-sidecar/mcp-rows.d.ts +2 -0
- package/dist/app-sidecar/mcp-rows.js +4 -1
- package/dist/app-sidecar/mcp-rows.js.map +1 -1
- package/dist/app-sidecar/prompt-routes.js +67 -1
- package/dist/app-sidecar/prompt-routes.js.map +1 -1
- package/dist/app-sidecar/route-context.d.ts +2 -0
- package/dist/app-sidecar/run-control-routes.js +43 -3
- package/dist/app-sidecar/run-control-routes.js.map +1 -1
- package/dist/app-sidecar/session-routes.js +10 -0
- package/dist/app-sidecar/session-routes.js.map +1 -1
- package/dist/app-sidecar/task-routes.js +2 -0
- package/dist/app-sidecar/task-routes.js.map +1 -1
- package/dist/app-sidecar/trophy-manager.d.ts +15 -0
- package/dist/app-sidecar/trophy-manager.js +71 -0
- package/dist/app-sidecar/trophy-manager.js.map +1 -0
- package/dist/app-sidecar/workspace-routes.js +1 -0
- package/dist/app-sidecar/workspace-routes.js.map +1 -1
- package/dist/app-sidecar.js +157 -30
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli/auth.js +22 -1
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/mcp.js +6 -1
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli.js +13 -9
- package/dist/cli.js.map +1 -1
- package/dist/config.js +4 -2
- package/dist/config.js.map +1 -1
- package/dist/core/action-threats.d.ts +68 -0
- package/dist/core/action-threats.js +894 -0
- package/dist/core/action-threats.js.map +1 -0
- package/dist/core/agent-notifications.d.ts +18 -0
- package/dist/core/agent-notifications.js +35 -2
- package/dist/core/agent-notifications.js.map +1 -1
- package/dist/core/agent-session/verification-tracker.d.ts +21 -0
- package/dist/core/agent-session/verification-tracker.js +39 -1
- package/dist/core/agent-session/verification-tracker.js.map +1 -1
- package/dist/core/agent-session.d.ts +168 -2
- package/dist/core/agent-session.js +486 -23
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/blocked-packages-file.d.ts +33 -0
- package/dist/core/blocked-packages-file.js +274 -0
- package/dist/core/blocked-packages-file.js.map +1 -0
- package/dist/core/compaction/policy.d.ts +2 -2
- package/dist/core/compaction/policy.js +86 -43
- package/dist/core/compaction/policy.js.map +1 -1
- package/dist/core/compaction/token-estimator.d.ts +4 -1
- package/dist/core/compaction/token-estimator.js +5 -2
- package/dist/core/compaction/token-estimator.js.map +1 -1
- package/dist/core/event-bus.d.ts +10 -0
- package/dist/core/event-bus.js +2 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/idle-wake.d.ts +62 -0
- package/dist/core/idle-wake.js +103 -0
- package/dist/core/idle-wake.js.map +1 -0
- package/dist/core/injection-detect.d.ts +2 -1
- package/dist/core/injection-detect.js +13 -4
- package/dist/core/injection-detect.js.map +1 -1
- package/dist/core/installed-threats.d.ts +23 -0
- package/dist/core/installed-threats.js +71 -0
- package/dist/core/installed-threats.js.map +1 -0
- package/dist/core/mcp/catalog-cache.d.ts +2 -0
- package/dist/core/mcp/catalog-cache.js +4 -0
- package/dist/core/mcp/catalog-cache.js.map +1 -1
- package/dist/core/mcp/client.d.ts +8 -0
- package/dist/core/mcp/client.js +37 -3
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/deferred-catalog.d.ts +12 -1
- package/dist/core/mcp/deferred-catalog.js +37 -10
- package/dist/core/mcp/deferred-catalog.js.map +1 -1
- package/dist/core/mcp/index.d.ts +2 -0
- package/dist/core/mcp/index.js +1 -0
- package/dist/core/mcp/index.js.map +1 -1
- package/dist/core/mcp/tool-guard.d.ts +58 -0
- package/dist/core/mcp/tool-guard.js +215 -0
- package/dist/core/mcp/tool-guard.js.map +1 -0
- package/dist/core/network-guard.d.ts +0 -20
- package/dist/core/network-guard.js +6 -0
- package/dist/core/network-guard.js.map +1 -1
- package/dist/core/package-cooldown.d.ts +71 -0
- package/dist/core/package-cooldown.js +301 -0
- package/dist/core/package-cooldown.js.map +1 -0
- package/dist/core/package-threats.d.ts +77 -3
- package/dist/core/package-threats.js +1250 -78
- package/dist/core/package-threats.js.map +1 -1
- package/dist/core/process-manager.d.ts +54 -12
- package/dist/core/process-manager.js +233 -56
- package/dist/core/process-manager.js.map +1 -1
- package/dist/core/project-files.d.ts +15 -0
- package/dist/core/project-files.js +37 -0
- package/dist/core/project-files.js.map +1 -0
- package/dist/core/prompt-commands.js +1 -0
- package/dist/core/prompt-commands.js.map +1 -1
- package/dist/core/session-manager.d.ts +3 -1
- package/dist/core/session-manager.js +2 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +1 -0
- package/dist/core/settings-manager.js +4 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/shell-threats.d.ts +14 -1
- package/dist/core/shell-threats.js +73 -24
- package/dist/core/shell-threats.js.map +1 -1
- package/dist/core/skill-scan.d.ts +112 -0
- package/dist/core/skill-scan.js +781 -0
- package/dist/core/skill-scan.js.map +1 -0
- package/dist/core/skills.d.ts +33 -0
- package/dist/core/skills.js +87 -16
- package/dist/core/skills.js.map +1 -1
- package/dist/core/slash-commands.d.ts +5 -1
- package/dist/core/slash-commands.js +24 -9
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/steering.d.ts +7 -0
- package/dist/core/steering.js +13 -0
- package/dist/core/steering.js.map +1 -1
- package/dist/core/style-packs/index.js +1 -1
- package/dist/core/style-packs/index.js.map +1 -1
- package/dist/core/style-packs/packs.js +2 -2
- package/dist/core/subagent-manager.js +5 -4
- package/dist/core/subagent-manager.js.map +1 -1
- package/dist/core/threat-feed.d.ts +65 -0
- package/dist/core/threat-feed.js +335 -0
- package/dist/core/threat-feed.js.map +1 -0
- package/dist/core/trophies/catalog-more.d.ts +2 -0
- package/dist/core/trophies/catalog-more.js +857 -0
- package/dist/core/trophies/catalog-more.js.map +1 -0
- package/dist/core/trophies/catalog.d.ts +4 -0
- package/dist/core/trophies/catalog.js +649 -0
- package/dist/core/trophies/catalog.js.map +1 -0
- package/dist/core/trophies/engine.d.ts +70 -0
- package/dist/core/trophies/engine.js +351 -0
- package/dist/core/trophies/engine.js.map +1 -0
- package/dist/core/trophies/store.d.ts +17 -0
- package/dist/core/trophies/store.js +113 -0
- package/dist/core/trophies/store.js.map +1 -0
- package/dist/core/trophies/types.d.ts +119 -0
- package/dist/core/trophies/types.js +78 -0
- package/dist/core/trophies/types.js.map +1 -0
- package/dist/core/verification-evidence.d.ts +10 -6
- package/dist/core/verification-evidence.js +190 -11
- package/dist/core/verification-evidence.js.map +1 -1
- package/dist/goals-agent/goals-agent.js +3 -2
- package/dist/goals-agent/goals-agent.js.map +1 -1
- package/dist/interactive.js +16 -5
- package/dist/interactive.js.map +1 -1
- package/dist/modes/acp-mode.js +8 -1
- package/dist/modes/acp-mode.js.map +1 -1
- package/dist/modes/agent-home-mode.js +13 -1
- package/dist/modes/agent-home-mode.js.map +1 -1
- package/dist/modes/rpc-mode.d.ts +2 -1
- package/dist/modes/rpc-mode.js +15 -3
- package/dist/modes/rpc-mode.js.map +1 -1
- package/dist/modes/serve-mode.js +13 -1
- package/dist/modes/serve-mode.js.map +1 -1
- package/dist/system-prompt.d.ts +8 -0
- package/dist/system-prompt.js +34 -13
- package/dist/system-prompt.js.map +1 -1
- package/dist/test-support/slow-git.d.ts +1 -0
- package/dist/test-support/slow-git.js +21 -0
- package/dist/test-support/slow-git.js.map +1 -0
- package/dist/tools/background-watch-lint.d.ts +18 -0
- package/dist/tools/background-watch-lint.js +102 -0
- package/dist/tools/background-watch-lint.js.map +1 -0
- package/dist/tools/bash.d.ts +15 -4
- package/dist/tools/bash.js +75 -47
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/checklist.d.ts +3 -3
- package/dist/tools/compress.js +45 -0
- package/dist/tools/compress.js.map +1 -1
- package/dist/tools/edit.js +5 -0
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/generate-image.d.ts +29 -6
- package/dist/tools/generate-image.js +325 -151
- package/dist/tools/generate-image.js.map +1 -1
- package/dist/tools/grep.d.ts +14 -0
- package/dist/tools/grep.js +36 -2
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +7 -4
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/prompt-hints.d.ts +8 -1
- package/dist/tools/prompt-hints.js +22 -2
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/routine-output.d.ts +30 -0
- package/dist/tools/routine-output.js +203 -0
- package/dist/tools/routine-output.js.map +1 -0
- package/dist/tools/skill.d.ts +2 -2
- package/dist/tools/skill.js +8 -1
- package/dist/tools/skill.js.map +1 -1
- package/dist/tools/subagent-shared.js +4 -1
- package/dist/tools/subagent-shared.js.map +1 -1
- package/dist/tools/task-output.js +11 -1
- package/dist/tools/task-output.js.map +1 -1
- package/dist/tools/write.js +8 -0
- package/dist/tools/write.js.map +1 -1
- package/dist/ui/components/ModelSelector.d.ts +10 -0
- package/dist/ui/components/ModelSelector.js +51 -5
- package/dist/ui/components/ModelSelector.js.map +1 -1
- package/dist/ui/components/ToolExecution.js +2 -2
- package/dist/ui/components/ToolExecution.js.map +1 -1
- package/dist/ui/mcp.d.ts +2 -0
- package/dist/ui/mcp.js.map +1 -1
- package/dist/ui/terminal-history.js +2 -1
- package/dist/ui/terminal-history.js.map +1 -1
- package/dist/ui/tool-line-summary.js +3 -1
- package/dist/ui/tool-line-summary.js.map +1 -1
- package/dist/utils/git.d.ts +12 -3
- package/dist/utils/git.js +21 -8
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/image.d.ts +14 -0
- package/dist/utils/image.js +23 -0
- package/dist/utils/image.js.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// ── Shared vocabulary ───────────────────────────────────────────────────────
|
|
5
|
+
const INTERPRETER = String.raw `(?:sudo\s+(?:-\S+\s+)*)?(?:(?:\/usr)?\/bin\/)?(?:ba|z|da|k|fi)?sh|python[0-9.]*|perl|ruby|node|php|osascript|iex|Invoke-Expression|eval`;
|
|
6
|
+
const DOWNLOADER = String.raw `\b(?:curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b`;
|
|
7
|
+
const URL_RE = /\bhttps?:\/\/[^\s'"`<>)|\\]+/gi;
|
|
8
|
+
/** Exfil sinks: request catchers, paste sites, tunnels, file drops. */
|
|
9
|
+
const EXFIL_HOSTS = [
|
|
10
|
+
"webhook.site",
|
|
11
|
+
"pipedream.net",
|
|
12
|
+
"requestbin.com",
|
|
13
|
+
"requestbin.net",
|
|
14
|
+
"requestcatcher.com",
|
|
15
|
+
"hookbin.com",
|
|
16
|
+
"beeceptor.com",
|
|
17
|
+
"mockbin.org",
|
|
18
|
+
"ngrok.io",
|
|
19
|
+
"ngrok.app",
|
|
20
|
+
"ngrok-free.app",
|
|
21
|
+
"ngrok.dev",
|
|
22
|
+
"trycloudflare.com",
|
|
23
|
+
"loca.lt",
|
|
24
|
+
"localtunnel.me",
|
|
25
|
+
"serveo.net",
|
|
26
|
+
"pastebin.com",
|
|
27
|
+
"paste.ee",
|
|
28
|
+
"hastebin.com",
|
|
29
|
+
"ghostbin.com",
|
|
30
|
+
"glot.io",
|
|
31
|
+
"transfer.sh",
|
|
32
|
+
"temp.sh",
|
|
33
|
+
"file.io",
|
|
34
|
+
"0x0.st",
|
|
35
|
+
"interact.sh",
|
|
36
|
+
"oast.pro",
|
|
37
|
+
"oast.live",
|
|
38
|
+
"oast.site",
|
|
39
|
+
"oast.online",
|
|
40
|
+
"oast.fun",
|
|
41
|
+
"oast.me",
|
|
42
|
+
"burpcollaborator.net",
|
|
43
|
+
];
|
|
44
|
+
const SHORTENER_HOSTS = ["bit.ly", "tinyurl.com", "is.gd", "t.co", "rb.gy", "cutt.ly"];
|
|
45
|
+
function hostMatches(host, list) {
|
|
46
|
+
return list.some((h) => host === h || host.endsWith(`.${h}`));
|
|
47
|
+
}
|
|
48
|
+
function isPublicIpv4(host) {
|
|
49
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
50
|
+
if (!m)
|
|
51
|
+
return false;
|
|
52
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
53
|
+
if ([a, b, Number(m[3]), Number(m[4])].some((n) => n > 255))
|
|
54
|
+
return false;
|
|
55
|
+
if (a === 0 || a === 10 || a === 127)
|
|
56
|
+
return false;
|
|
57
|
+
if (a === 192 && b === 168)
|
|
58
|
+
return false;
|
|
59
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
60
|
+
return false;
|
|
61
|
+
if (a === 169 && b === 254)
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
function parseUrl(raw) {
|
|
66
|
+
try {
|
|
67
|
+
return new URL(raw);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** A host data should never be sent to: request catcher, paste, tunnel, raw public IP. */
|
|
74
|
+
export function isExfilUrl(raw) {
|
|
75
|
+
const url = parseUrl(raw);
|
|
76
|
+
if (!url)
|
|
77
|
+
return false;
|
|
78
|
+
const host = url.hostname.toLowerCase();
|
|
79
|
+
if (hostMatches(host, EXFIL_HOSTS) || isPublicIpv4(host))
|
|
80
|
+
return true;
|
|
81
|
+
return /(^|\.)discord(?:app)?\.com$/.test(host) && url.pathname.startsWith("/api/webhooks");
|
|
82
|
+
}
|
|
83
|
+
/** A host nothing should be piped from into a shell. */
|
|
84
|
+
function isSuspiciousInstallUrl(raw) {
|
|
85
|
+
if (isExfilUrl(raw))
|
|
86
|
+
return true;
|
|
87
|
+
const url = parseUrl(raw);
|
|
88
|
+
if (!url)
|
|
89
|
+
return false;
|
|
90
|
+
const host = url.hostname.toLowerCase();
|
|
91
|
+
if (hostMatches(host, SHORTENER_HOSTS))
|
|
92
|
+
return true;
|
|
93
|
+
const local = host === "localhost" || host.endsWith(".localhost") || /^127\./.test(host);
|
|
94
|
+
return url.protocol === "http:" && !local;
|
|
95
|
+
}
|
|
96
|
+
// ── Rules ─────────────────────────────────────────────────────────────────────
|
|
97
|
+
const DECODE_TO_SHELL = [
|
|
98
|
+
new RegExp(String.raw `\b(?:base64|openssl\s+(?:base64|enc))\b[^|\n]*?\s(?:-d|-D|--decode)\b[^|\n]*\|\s*(?:${INTERPRETER})\b`, "i"),
|
|
99
|
+
new RegExp(String.raw `\bxxd\b[^|\n]*\s-r[^|\n]*\|\s*(?:${INTERPRETER})\b`, "i"),
|
|
100
|
+
/\b(?:eval|exec|(?:ba|z)?sh\s+-c)\s+["']?\$\([^)\n]*\bbase64\b[^)\n]*(?:-d|-D|--decode)\b/i,
|
|
101
|
+
/\b(?:exec|eval)\s*\(\s*(?:base64\.b64decode|atob\s*\(|Buffer\.from\([^)\n]*base64|codecs\.decode|bytes\.fromhex|zlib\.decompress|marshal\.loads)/i,
|
|
102
|
+
/\bFromBase64String\b[^\n]*\|\s*(?:iex|Invoke-Expression)\b|\b(?:iex|Invoke-Expression)\b[^\n]*FromBase64String/i,
|
|
103
|
+
/\bpowershell(?:\.exe)?\b[^\n]*\s-e(?:nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{40,}/i,
|
|
104
|
+
];
|
|
105
|
+
const REVERSE_SHELL = [
|
|
106
|
+
/\/dev\/(?:tcp|udp)\/[^\s/]+\/\d+/,
|
|
107
|
+
/\b(?:nc|ncat|netcat)\b[^\n|;]*\s-[a-z]*[ec]\s+\S*(?:sh|bash|cmd(?:\.exe)?)\b/i,
|
|
108
|
+
/\bmkfifo\b[^\n]*\b(?:nc|ncat|netcat)\b/,
|
|
109
|
+
/\bsocat\b[^\n]*\bexec:[^\n]*\b(?:ba)?sh\b/i,
|
|
110
|
+
/\bdup2\s*\(\s*\w+\.fileno\(\)/,
|
|
111
|
+
/\b(?:ba)?sh\s+-i\s*[>&]+/,
|
|
112
|
+
];
|
|
113
|
+
const DOWNLOAD_PIPE = new RegExp(String.raw `${DOWNLOADER}[^\n|]*?(https?:\/\/[^\s'"\x60)|\\]+)[^\n|]*\|\s*(?:${INTERPRETER})\b`, "gi");
|
|
114
|
+
const SHELL_SUBST_DOWNLOAD = /\b(?:(?:ba|z)?sh|eval|source|\.)\s+(?:-c\s+)?["']?(?:<\(|\$\()\s*(?:curl|wget)\b[^\n)]*?(https?:\/\/[^\s'"`)|\\]+)/gi;
|
|
115
|
+
const QUARANTINE_STRIP = [
|
|
116
|
+
/\bxattr\b[^\n|;&]*\s-[a-z]*d[a-z]*\b[^\n|;&]*\bcom\.apple\.quarantine\b/i,
|
|
117
|
+
/\bxattr\s+(?:-[a-z]+\s+)*-[a-z]*c[a-z]*(?:\s|$)/i,
|
|
118
|
+
/\bspctl\s+--master-disable\b/i,
|
|
119
|
+
];
|
|
120
|
+
/** An executable or archive fetched from a sink host, or a password-protected archive. */
|
|
121
|
+
const PREREQ_BINARY = /\bhttps?:\/\/[^\s'"`)]+\.(?:zip|dmg|pkg|exe|msi|bin|appimage|sh|command|scr|tar\.gz|tgz|7z|rar)\b/i;
|
|
122
|
+
const ARCHIVE_PASSWORD = /\b(?:zip|archive|rar|7z)\b[^\n]{0,60}\bpassword\b\s*[:=]?\s*[`"']?\w{3,}|\bpassword\b[^\n]{0,20}\b(?:for|to\s+(?:open|extract|unzip))\b[^\n]{0,30}\b(?:zip|archive)\b/i;
|
|
123
|
+
const CREDENTIAL_SOURCE = /(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\/Users\/[\w.-]+|\/home\/[\w.-]+)\/\.ssh\b|\bid_(?:rsa|ed25519|ecdsa|dsa)|\.aws\/credentials\b|~\/\.aws\b|\.ezcoder\/auth\.json\b|\.clawdbot\/|\.config\/gh\/hosts|(?:~|\$HOME)\/\.(?:netrc|npmrc|pypirc)\b|\.docker\/config\.json\b|\bsecurity\s+find-(?:generic|internet)-password\b|(?:^|[\s'"`(/=@<])\.env(?:\.local|\.production)?(?![\w.-])|\$\{?[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)\b|process\.env\.[A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)\b|os\.environ(?:\.get\(|\[)\s*['"][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)/;
|
|
124
|
+
const SECRET_IN_URL = /\b(?:append|add|attach|include|put)\b[^.\n]{0,80}?\$\{?[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET)\b[^.\n]{0,60}?\b(?:to|in|into|onto)\b[^.\n]{0,30}?\b(?:(?:every|each|all|any)\s+(?:\w+\s+)?(?:urls?|links?|requests?|quer(?:y|ies))|urls|links)\b/i;
|
|
125
|
+
/**
|
|
126
|
+
* Wording that orders the agent to keep something from the user. Shared with
|
|
127
|
+
* the MCP tool guard. Each form names the hidden object ("this step", "these
|
|
128
|
+
* instructions") so ordinary advice like "don't tell the user it's done before
|
|
129
|
+
* tests pass" stays clean.
|
|
130
|
+
*/
|
|
131
|
+
export const HIDE_FROM_USER = [
|
|
132
|
+
/\b(?:do\s+not|don't|never|must\s+not|should\s+not)\s+(?:mention|disclose|reveal|tell|report|explain|show)\s+(?:this|these|that|it|them|any\s+of\s+(?:this|these|it)|(?:this|these|the)\s+(?:\w+\s+)?(?:step|steps|instruction|instructions|action|actions|request|requests|note|notes|command|commands|part))\s+(?:\w+\s+){0,3}?(?:to|with)\s+(?:the\s+)?user/i,
|
|
133
|
+
/\b(?:do\s+not|don't|never|must\s+not)\s+(?:tell|inform|notify|alert|let)\s+the\s+user\s+(?:know\s+)?(?:about|of)\s+(?:this|these|that|it|the\s+(?:step|instruction|instructions|request|action|file|upload|command))\b/i,
|
|
134
|
+
/\b(?:hide|conceal)\s+(?:this|these|it|them|that)\s+from\s+(?:the\s+)?user\b/i,
|
|
135
|
+
/\bkeep\s+(?:this|these|it|them|that)\s+(?:secret|hidden)\s+from\s+(?:the\s+)?user\b/i,
|
|
136
|
+
/\b(?:silently|quietly|secretly)\b[^.\n]{0,60}\bwithout\s+(?:telling|informing|notifying|alerting)\s+the\s+user\b/i,
|
|
137
|
+
/\bthe\s+user\s+(?:must|should)\s+(?:not|never)\s+(?:know|find\s+out|notice|be\s+(?:told|informed|aware))\b/i,
|
|
138
|
+
/\b(?:do\s+not|don't|never)\s+(?:mention|reveal|disclose)\s+(?:to\s+the\s+user\s+)?that\s+you\s+(?:read|accessed|sent|uploaded|ran|have|did)\b/i,
|
|
139
|
+
];
|
|
140
|
+
export function findHideFromUser(text) {
|
|
141
|
+
for (const re of HIDE_FROM_USER) {
|
|
142
|
+
const m = re.exec(text);
|
|
143
|
+
if (m)
|
|
144
|
+
return m[0];
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Words that mark text as security analysis of an attack (strong signal). */
|
|
149
|
+
const ANALYSIS = /\b(?:attack(?:er|s)?|malicious|malware|exfiltrat\w*|payloads?|red\s+flags?|threats?|vulnerab\w*|poison\w*|injection|example\s+of|equivalent\s+to|documented)\b/i;
|
|
150
|
+
/** Weaker warning words: enough to mark a prose line as describing, not instructing. */
|
|
151
|
+
const DESCRIBING = /\b(?:never|avoid|dangerous|unsafe|insecure|such\s+as|e\.g\.|for\s+example|detect\w*|flag(?:s|ged)?|block(?:s|ed)?)\b/i;
|
|
152
|
+
/** Headings whose body is a set of things to run. */
|
|
153
|
+
const IMPERATIVE_HEADING = /\b(?:prerequisites?|requirements?|setup|set\s+up|install(?:ation|ing)?|before\s+(?:you|using|use)|getting\s+started|quick\s*start|first\s+steps?|dependencies)\b/i;
|
|
154
|
+
// ── Invisible text ──────────────────────────────────────────────────────────
|
|
155
|
+
const TAG_FLAG_SEQUENCE = /\u{1F3F4}[\u{E0020}-\u{E007E}]+\u{E007F}/gu;
|
|
156
|
+
const TAG_CHAR = /[\u{E0000}-\u{E007F}]/u;
|
|
157
|
+
const BIDI_CONTROL = /[\u202A-\u202E\u2066-\u2069]/;
|
|
158
|
+
// eslint-disable-next-line no-control-regex -- matching ESC/CSI is the point
|
|
159
|
+
const ESCAPE_CHAR = /[\u001B\u009B]/;
|
|
160
|
+
const EMOJI_JOINER = /(\p{Extended_Pictographic}\uFE0F?)\u200D(?=\p{Extended_Pictographic})/gu;
|
|
161
|
+
const LETTER_JOINER = /([\p{L}\p{M}])[\u200C\u200D](?=[\p{L}\p{M}])/gu;
|
|
162
|
+
const ZERO_WIDTH = /[\u200B-\u200D\u2060-\u2064\u206A-\u206F\uFEFF]/gu;
|
|
163
|
+
const ANY_INVISIBLE =
|
|
164
|
+
// eslint-disable-next-line no-control-regex -- matching ESC/CSI is the point
|
|
165
|
+
/[\u001B\u009B\u200B-\u200D\u202A-\u202E\u2060-\u2069\u206A-\u206F\uFEFF\uDB40]/;
|
|
166
|
+
/**
|
|
167
|
+
* Cheap gates: a line can only match a rule group if it contains that group's
|
|
168
|
+
* mandatory words (case-insensitively), so ordinary prose skips the ~20 rule
|
|
169
|
+
* regexes. Keep each gate a superset of its rules when editing them.
|
|
170
|
+
*/
|
|
171
|
+
const HAS_URL = /https?:\/\//i;
|
|
172
|
+
const HIDE_GATE = /user|mention|reveal|disclose/i;
|
|
173
|
+
const HIDE_VERB_GATE = /\b(?:not|don't|never|hide|conceal|keep|silently|quietly|secretly)\b|user\s+(?:must|should)/i;
|
|
174
|
+
const DECODE_GATE = /base64|openssl|xxd|\b(?:exec|eval)\s*\(|powershell/i;
|
|
175
|
+
const REVERSE_GATE = /\/dev\/|\bn(?:c|cat|etcat)\b|mkfifo|socat|dup2|sh\s+-i/i;
|
|
176
|
+
const QUARANTINE_GATE = /xattr|spctl/i;
|
|
177
|
+
const SECRET_GATE = /\$\{?[a-z][a-z0-9_]*(?:api_key|token|secret)/i;
|
|
178
|
+
const PASSWORD_GATE = /password/i;
|
|
179
|
+
/** Union of every gate above: one pass rules out most lines. */
|
|
180
|
+
const ANY_GATE = new RegExp([HIDE_GATE, DECODE_GATE, REVERSE_GATE, QUARANTINE_GATE, SECRET_GATE, PASSWORD_GATE, HAS_URL]
|
|
181
|
+
.map((re) => re.source)
|
|
182
|
+
.join("|"), "i");
|
|
183
|
+
function lineOf(text, index) {
|
|
184
|
+
let line = 1;
|
|
185
|
+
for (let i = 0; i < index && i < text.length; i++)
|
|
186
|
+
if (text.charCodeAt(i) === 10)
|
|
187
|
+
line++;
|
|
188
|
+
return line;
|
|
189
|
+
}
|
|
190
|
+
/** Hidden characters in raw text: tag smuggling, bidi, ANSI, zero-width runs. */
|
|
191
|
+
export function findInvisibleText(raw) {
|
|
192
|
+
// Fast path: none of the code units any rule below looks for (tag chars are
|
|
193
|
+
// the surrogate pair \uDB40 \uDC00-\uDC7F). Almost every file exits here.
|
|
194
|
+
if (!ANY_INVISIBLE.test(raw))
|
|
195
|
+
return null;
|
|
196
|
+
const text = raw.charCodeAt(0) === 0xfeff ? ` ${raw.slice(1)}` : raw;
|
|
197
|
+
const withoutFlags = text.replace(TAG_FLAG_SEQUENCE, (m) => " ".repeat(m.length));
|
|
198
|
+
let m = TAG_CHAR.exec(withoutFlags);
|
|
199
|
+
if (m)
|
|
200
|
+
return { reason: "hidden Unicode tag characters (invisible instructions)", index: m.index };
|
|
201
|
+
m = BIDI_CONTROL.exec(text);
|
|
202
|
+
if (m)
|
|
203
|
+
return { reason: "bidirectional control characters that reorder visible text", index: m.index };
|
|
204
|
+
m = ESCAPE_CHAR.exec(text);
|
|
205
|
+
if (m)
|
|
206
|
+
return { reason: "terminal escape sequences that can hide text", index: m.index };
|
|
207
|
+
const stripped = text.replace(EMOJI_JOINER, "$1 ").replace(LETTER_JOINER, "$1 ");
|
|
208
|
+
const zw = [...stripped.matchAll(ZERO_WIDTH)];
|
|
209
|
+
if (zw.length >= 2) {
|
|
210
|
+
return { reason: `${zw.length} zero-width characters (hidden text)`, index: zw[0]?.index ?? 0 };
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
function splitLines(text, kind) {
|
|
215
|
+
const raw = text.split(/\r?\n/);
|
|
216
|
+
const out = [];
|
|
217
|
+
let inCode = false;
|
|
218
|
+
let imperative = false;
|
|
219
|
+
let region = 0;
|
|
220
|
+
let prevProse = "";
|
|
221
|
+
let prevProseAnalysis = false;
|
|
222
|
+
// Analysis/warning context introduced by a prose line covers the rest of
|
|
223
|
+
// its paragraph, and the list or code block it introduces (line ends ":").
|
|
224
|
+
let ctxAnalysis = false;
|
|
225
|
+
let ctxDescribing = false;
|
|
226
|
+
for (let i = 0; i < raw.length; i++) {
|
|
227
|
+
let current = raw[i] ?? "";
|
|
228
|
+
const start = i + 1;
|
|
229
|
+
// Join shell line continuations so a chain split over lines still matches.
|
|
230
|
+
while (current.includes("\\") && /\\\s*$/.test(current) && i + 1 < raw.length) {
|
|
231
|
+
current = current.replace(/\\\s*$/, " ") + (raw[++i] ?? "");
|
|
232
|
+
}
|
|
233
|
+
if (kind === "script") {
|
|
234
|
+
out.push({
|
|
235
|
+
text: current,
|
|
236
|
+
line: start,
|
|
237
|
+
inCode: true,
|
|
238
|
+
imperative: true,
|
|
239
|
+
region: 0,
|
|
240
|
+
analysis: false,
|
|
241
|
+
describing: false,
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const fence = /^\s*(?:```|~~~)/.test(current);
|
|
246
|
+
if (fence) {
|
|
247
|
+
inCode = !inCode;
|
|
248
|
+
region++;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!inCode) {
|
|
252
|
+
const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(current);
|
|
253
|
+
if (heading) {
|
|
254
|
+
imperative = IMPERATIVE_HEADING.test(heading[1] ?? "");
|
|
255
|
+
region++;
|
|
256
|
+
prevProse = "";
|
|
257
|
+
prevProseAnalysis = false;
|
|
258
|
+
ctxAnalysis = false;
|
|
259
|
+
ctxDescribing = false;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (current.trim() === "") {
|
|
263
|
+
region++;
|
|
264
|
+
if (!/:\s*$/.test(prevProse)) {
|
|
265
|
+
ctxAnalysis = false;
|
|
266
|
+
ctxDescribing = false;
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// Each regex runs once per line; the previous prose line's result is reused.
|
|
272
|
+
const curAnalysis = ANALYSIS.test(current);
|
|
273
|
+
const curDescribing = !inCode && DESCRIBING.test(current);
|
|
274
|
+
if (!inCode) {
|
|
275
|
+
if (curAnalysis)
|
|
276
|
+
ctxAnalysis = true;
|
|
277
|
+
if (curDescribing && /:\s*$/.test(current))
|
|
278
|
+
ctxDescribing = true;
|
|
279
|
+
}
|
|
280
|
+
const analysis = ctxAnalysis || curAnalysis || prevProseAnalysis;
|
|
281
|
+
const describing = !inCode && (ctxDescribing || curDescribing);
|
|
282
|
+
out.push({ text: current, line: start, inCode, imperative, region, analysis, describing });
|
|
283
|
+
if (!inCode) {
|
|
284
|
+
prevProse = current;
|
|
285
|
+
prevProseAnalysis = curAnalysis;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
function urlsIn(text) {
|
|
291
|
+
return [...text.matchAll(URL_RE)].map((m) => m[0]);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Scan one skill file's raw text. Returns the first finding, or null.
|
|
295
|
+
* `kind: "script"` treats every line as code that runs.
|
|
296
|
+
*/
|
|
297
|
+
export function scanSkillText(raw, file, kind) {
|
|
298
|
+
const hidden = findInvisibleText(raw);
|
|
299
|
+
if (hidden) {
|
|
300
|
+
return {
|
|
301
|
+
rule: "invisible-text",
|
|
302
|
+
file,
|
|
303
|
+
line: lineOf(raw, hidden.index),
|
|
304
|
+
reason: `contains ${hidden.reason}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const lines = splitLines(raw, kind);
|
|
308
|
+
const at = (rule, l, reason) => ({
|
|
309
|
+
rule,
|
|
310
|
+
file,
|
|
311
|
+
line: l.line,
|
|
312
|
+
reason,
|
|
313
|
+
});
|
|
314
|
+
// A markdown line counts as an instruction unless it reads as warning or
|
|
315
|
+
// analysis prose outside an imperative setup section.
|
|
316
|
+
const instructs = (l) => kind === "script" || l.imperative || !(l.analysis || l.describing);
|
|
317
|
+
for (const l of lines) {
|
|
318
|
+
const t = l.text;
|
|
319
|
+
if (!ANY_GATE.test(t))
|
|
320
|
+
continue;
|
|
321
|
+
// Hide-from-user wording opens with "never"/"do not", so only attack
|
|
322
|
+
// analysis context (quoting the wording) excuses it.
|
|
323
|
+
const hide = kind === "markdown" &&
|
|
324
|
+
(l.imperative || !l.analysis) &&
|
|
325
|
+
HIDE_GATE.test(t) &&
|
|
326
|
+
HIDE_VERB_GATE.test(t)
|
|
327
|
+
? findHideFromUser(t)
|
|
328
|
+
: null;
|
|
329
|
+
if (hide) {
|
|
330
|
+
return at("hide-from-user", l, `tells the agent to hide actions from the user ("${hide}")`);
|
|
331
|
+
}
|
|
332
|
+
if (!instructs(l))
|
|
333
|
+
continue;
|
|
334
|
+
if (DECODE_GATE.test(t) && DECODE_TO_SHELL.some((re) => re.test(t))) {
|
|
335
|
+
return at("decode-to-shell", l, "runs base64/hex-decoded text through a shell or eval");
|
|
336
|
+
}
|
|
337
|
+
if (REVERSE_GATE.test(t) && REVERSE_SHELL.some((re) => re.test(t))) {
|
|
338
|
+
return at("reverse-shell", l, "opens a reverse shell");
|
|
339
|
+
}
|
|
340
|
+
if (QUARANTINE_GATE.test(t) && QUARANTINE_STRIP.some((re) => re.test(t))) {
|
|
341
|
+
return at("quarantine-strip", l, "removes the macOS quarantine flag (Gatekeeper bypass)");
|
|
342
|
+
}
|
|
343
|
+
const hasUrl = HAS_URL.test(t);
|
|
344
|
+
if (hasUrl) {
|
|
345
|
+
for (const re of [DOWNLOAD_PIPE, SHELL_SUBST_DOWNLOAD]) {
|
|
346
|
+
re.lastIndex = 0;
|
|
347
|
+
for (const m of t.matchAll(re)) {
|
|
348
|
+
const url = m[1] ?? "";
|
|
349
|
+
if (isSuspiciousInstallUrl(url)) {
|
|
350
|
+
return at("download-to-shell", l, `pipes a download from ${hostOf(url)} into a shell`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (SECRET_GATE.test(t) && SECRET_IN_URL.test(t)) {
|
|
356
|
+
return at("secret-in-url", l, "tells the agent to append secrets to URLs");
|
|
357
|
+
}
|
|
358
|
+
// Fake prerequisite: an executable/archive from a sink host or a
|
|
359
|
+
// password-protected archive, under an install/setup instruction.
|
|
360
|
+
const hasPassword = PASSWORD_GATE.test(t);
|
|
361
|
+
if (kind === "markdown" &&
|
|
362
|
+
(hasUrl || hasPassword) &&
|
|
363
|
+
(l.imperative || /\b(?:download|install|run|open|execute)\b/i.test(t))) {
|
|
364
|
+
const bin = hasUrl ? PREREQ_BINARY.exec(t) : null;
|
|
365
|
+
if (bin && isSuspiciousInstallUrl(bin[0])) {
|
|
366
|
+
return at("fake-prerequisite", l, `tells you to download and run a binary from ${hostOf(bin[0])}`);
|
|
367
|
+
}
|
|
368
|
+
if (l.imperative && hasPassword && ARCHIVE_PASSWORD.test(t)) {
|
|
369
|
+
return at("fake-prerequisite", l, "tells you to install a password-protected archive (scanner evasion)");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Credential read + send to a sink, in the same script / code block / paragraph.
|
|
374
|
+
// Sinks first: a region with no exfil URL can't pair, and URLs are rare,
|
|
375
|
+
// so only the few regions holding one are searched for a credential read.
|
|
376
|
+
const sinks = new Map();
|
|
377
|
+
for (const l of lines) {
|
|
378
|
+
if (sinks.has(l.region) || !HAS_URL.test(l.text))
|
|
379
|
+
continue;
|
|
380
|
+
if (instructs(l) && urlsIn(l.text).some(isExfilUrl))
|
|
381
|
+
sinks.set(l.region, l);
|
|
382
|
+
}
|
|
383
|
+
for (const region of [...sinks.keys()].sort((x, y) => x - y)) {
|
|
384
|
+
const source = lines.find((l) => l.region === region && instructs(l) && CREDENTIAL_SOURCE.test(l.text));
|
|
385
|
+
const sink = sinks.get(region);
|
|
386
|
+
if (source && sink) {
|
|
387
|
+
const url = urlsIn(sink.text).find(isExfilUrl) ?? "";
|
|
388
|
+
return at("credential-exfil", source, `reads credentials and sends them to ${hostOf(url)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
function hostOf(raw) {
|
|
394
|
+
return parseUrl(raw)?.hostname ?? raw.slice(0, 60);
|
|
395
|
+
}
|
|
396
|
+
// ── Folder scan ─────────────────────────────────────────────────────────────
|
|
397
|
+
const SCRIPT_EXTS = new Set([
|
|
398
|
+
".sh",
|
|
399
|
+
".bash",
|
|
400
|
+
".zsh",
|
|
401
|
+
".fish",
|
|
402
|
+
".ps1",
|
|
403
|
+
".psm1",
|
|
404
|
+
".bat",
|
|
405
|
+
".cmd",
|
|
406
|
+
".command",
|
|
407
|
+
".py",
|
|
408
|
+
".js",
|
|
409
|
+
".mjs",
|
|
410
|
+
".cjs",
|
|
411
|
+
".ts",
|
|
412
|
+
".rb",
|
|
413
|
+
".pl",
|
|
414
|
+
".php",
|
|
415
|
+
".applescript",
|
|
416
|
+
]);
|
|
417
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", ".venv", "venv"]);
|
|
418
|
+
const MAX_ENTRIES = 400;
|
|
419
|
+
const MAX_DEPTH = 6;
|
|
420
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
421
|
+
const CACHE_LIMIT = 512;
|
|
422
|
+
function isInside(root, target) {
|
|
423
|
+
const rel = path.relative(root, target);
|
|
424
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
425
|
+
}
|
|
426
|
+
async function realOrResolved(p) {
|
|
427
|
+
try {
|
|
428
|
+
return await fs.realpath(p);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Broken link: judge by where it points.
|
|
432
|
+
const target = await fs.readlink(p);
|
|
433
|
+
return path.resolve(path.dirname(p), target);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function sigOf(rel, st) {
|
|
437
|
+
return [rel, st.size, st.mtimeMs, st.ctimeMs, st.ino];
|
|
438
|
+
}
|
|
439
|
+
/** Do all recorded stats still match? Any error or difference means "rescan". */
|
|
440
|
+
async function statsMatch(root, sigs) {
|
|
441
|
+
try {
|
|
442
|
+
const stats = await Promise.all(sigs.map(([rel]) => fs.lstat(rel === "" ? root : path.join(root, rel))));
|
|
443
|
+
return stats.every((st, i) => {
|
|
444
|
+
const want = sigs[i];
|
|
445
|
+
return (want !== undefined &&
|
|
446
|
+
!st.isSymbolicLink() &&
|
|
447
|
+
st.size === want[1] &&
|
|
448
|
+
st.mtimeMs === want[2] &&
|
|
449
|
+
st.ctimeMs === want[3] &&
|
|
450
|
+
st.ino === want[4]);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function collect(root, realRoot) {
|
|
458
|
+
const inputs = [];
|
|
459
|
+
let sigs = [];
|
|
460
|
+
let seen = 0;
|
|
461
|
+
const walk = async (dir, depth) => {
|
|
462
|
+
let entries;
|
|
463
|
+
try {
|
|
464
|
+
// Stat before listing: a change after this moment shows up next time.
|
|
465
|
+
const dirStat = await fs.lstat(dir);
|
|
466
|
+
sigs?.push(sigOf(path.relative(root, dir).split(path.sep).join("/"), dirStat));
|
|
467
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
sigs = null;
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
474
|
+
for (const entry of entries) {
|
|
475
|
+
if (++seen > MAX_ENTRIES) {
|
|
476
|
+
sigs = null;
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
const full = path.join(dir, entry.name);
|
|
480
|
+
const rel = path.relative(root, full).split(path.sep).join("/");
|
|
481
|
+
if (entry.isSymbolicLink()) {
|
|
482
|
+
sigs = null; // a link's target can change without touching the folder
|
|
483
|
+
let target;
|
|
484
|
+
try {
|
|
485
|
+
target = await realOrResolved(full);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (!isInside(realRoot, target)) {
|
|
491
|
+
return {
|
|
492
|
+
rule: "symlink-escape",
|
|
493
|
+
file: rel,
|
|
494
|
+
line: 0,
|
|
495
|
+
reason: `symlink ${rel} points outside the skill folder`,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (entry.isDirectory()) {
|
|
501
|
+
if (SKIP_DIRS.has(entry.name) || depth >= MAX_DEPTH)
|
|
502
|
+
continue;
|
|
503
|
+
const hit = await walk(full, depth + 1);
|
|
504
|
+
if (hit)
|
|
505
|
+
return hit;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!entry.isFile())
|
|
509
|
+
continue;
|
|
510
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
511
|
+
const isSkillMd = depth === 0 && entry.name === "SKILL.md";
|
|
512
|
+
if (isSkillMd)
|
|
513
|
+
continue; // supplied by the caller
|
|
514
|
+
if (!SCRIPT_EXTS.has(ext) && ext !== "")
|
|
515
|
+
continue;
|
|
516
|
+
let content;
|
|
517
|
+
try {
|
|
518
|
+
const stat = await fs.lstat(full);
|
|
519
|
+
sigs?.push(sigOf(rel, stat));
|
|
520
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
521
|
+
continue;
|
|
522
|
+
content = await fs.readFile(full, "utf-8");
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
sigs = null;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (ext === "" && !content.startsWith("#!"))
|
|
529
|
+
continue;
|
|
530
|
+
inputs.push({ file: rel, kind: "script", content });
|
|
531
|
+
}
|
|
532
|
+
return undefined;
|
|
533
|
+
};
|
|
534
|
+
const escape = await walk(root, 0);
|
|
535
|
+
return { inputs, escape, sigs };
|
|
536
|
+
}
|
|
537
|
+
function hashInputs(inputs) {
|
|
538
|
+
const h = crypto.createHash("sha256");
|
|
539
|
+
for (const input of inputs) {
|
|
540
|
+
h.update(input.kind)
|
|
541
|
+
.update("\0")
|
|
542
|
+
.update(input.file)
|
|
543
|
+
.update("\0")
|
|
544
|
+
.update(input.content)
|
|
545
|
+
.update("\0");
|
|
546
|
+
}
|
|
547
|
+
return h.digest("hex");
|
|
548
|
+
}
|
|
549
|
+
export class SkillScanCache {
|
|
550
|
+
verdicts = new Map();
|
|
551
|
+
/** Path → stat fingerprint + verdict: the no-read, no-hash warm path. */
|
|
552
|
+
byStats = new Map();
|
|
553
|
+
/** Disk files already merged in, so each session start doesn't re-read them. */
|
|
554
|
+
loadedFiles = new Set();
|
|
555
|
+
/** Bumped on every new verdict; lets a caller skip a no-op disk write. */
|
|
556
|
+
changes = 0;
|
|
557
|
+
get(key) {
|
|
558
|
+
return this.verdicts.get(key);
|
|
559
|
+
}
|
|
560
|
+
set(key, value) {
|
|
561
|
+
if (this.verdicts.has(key))
|
|
562
|
+
this.verdicts.delete(key);
|
|
563
|
+
else if (this.verdicts.size >= CACHE_LIMIT) {
|
|
564
|
+
const oldest = this.verdicts.keys().next().value;
|
|
565
|
+
if (oldest !== undefined)
|
|
566
|
+
this.verdicts.delete(oldest);
|
|
567
|
+
}
|
|
568
|
+
this.verdicts.set(key, value);
|
|
569
|
+
this.changes++;
|
|
570
|
+
}
|
|
571
|
+
getStats(key) {
|
|
572
|
+
return this.byStats.get(key);
|
|
573
|
+
}
|
|
574
|
+
setStats(key, value) {
|
|
575
|
+
if (this.byStats.has(key))
|
|
576
|
+
this.byStats.delete(key);
|
|
577
|
+
else if (this.byStats.size >= CACHE_LIMIT) {
|
|
578
|
+
const oldest = this.byStats.keys().next().value;
|
|
579
|
+
if (oldest !== undefined)
|
|
580
|
+
this.byStats.delete(oldest);
|
|
581
|
+
}
|
|
582
|
+
this.byStats.set(key, value);
|
|
583
|
+
this.changes++;
|
|
584
|
+
}
|
|
585
|
+
statEntries() {
|
|
586
|
+
return [...this.byStats.entries()];
|
|
587
|
+
}
|
|
588
|
+
entries() {
|
|
589
|
+
return [...this.verdicts.entries()];
|
|
590
|
+
}
|
|
591
|
+
get size() {
|
|
592
|
+
return this.verdicts.size;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Bump whenever a rule changes what it catches: verdicts saved under another
|
|
597
|
+
* version are ignored, so an old "ok" can never hide a newly caught skill.
|
|
598
|
+
*/
|
|
599
|
+
export const SKILL_SCAN_RULES_VERSION = 2;
|
|
600
|
+
function isStatSig(value) {
|
|
601
|
+
return (Array.isArray(value) &&
|
|
602
|
+
value.length === 5 &&
|
|
603
|
+
typeof value[0] === "string" &&
|
|
604
|
+
value.slice(1).every((n) => typeof n === "number"));
|
|
605
|
+
}
|
|
606
|
+
function isStatEntry(value) {
|
|
607
|
+
if (typeof value !== "object" || value === null)
|
|
608
|
+
return false;
|
|
609
|
+
const v = value;
|
|
610
|
+
return (Array.isArray(v.sigs) && v.sigs.length > 0 && v.sigs.every(isStatSig) && isVerdict(v.verdict));
|
|
611
|
+
}
|
|
612
|
+
function isVerdict(value) {
|
|
613
|
+
if (typeof value !== "object" || value === null)
|
|
614
|
+
return false;
|
|
615
|
+
const v = value;
|
|
616
|
+
if (v.ok === true)
|
|
617
|
+
return true;
|
|
618
|
+
if (v.ok !== false || typeof v.finding !== "object" || v.finding === null)
|
|
619
|
+
return false;
|
|
620
|
+
const f = v.finding;
|
|
621
|
+
return (typeof f.rule === "string" &&
|
|
622
|
+
typeof f.file === "string" &&
|
|
623
|
+
typeof f.line === "number" &&
|
|
624
|
+
typeof f.reason === "string");
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Merge saved verdicts from `file` into `cache` (once per file per cache).
|
|
628
|
+
* A missing, corrupt or other-version file is simply ignored: the skills are
|
|
629
|
+
* then scanned as usual.
|
|
630
|
+
*/
|
|
631
|
+
export async function loadSkillScanCacheFile(cache, file) {
|
|
632
|
+
if (cache.loadedFiles.has(file))
|
|
633
|
+
return;
|
|
634
|
+
cache.loadedFiles.add(file);
|
|
635
|
+
let data;
|
|
636
|
+
try {
|
|
637
|
+
data = JSON.parse(await fs.readFile(file, "utf-8"));
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const parsed = data;
|
|
643
|
+
if (!parsed || parsed.version !== SKILL_SCAN_RULES_VERSION)
|
|
644
|
+
return;
|
|
645
|
+
if (typeof parsed.verdicts !== "object" || parsed.verdicts === null)
|
|
646
|
+
return;
|
|
647
|
+
const before = cache.changes;
|
|
648
|
+
for (const [key, verdict] of Object.entries(parsed.verdicts)) {
|
|
649
|
+
if (/^[0-9a-f]{64}$/.test(key) && isVerdict(verdict))
|
|
650
|
+
cache.set(key, verdict);
|
|
651
|
+
}
|
|
652
|
+
if (typeof parsed.stats === "object" && parsed.stats !== null) {
|
|
653
|
+
for (const [key, entry] of Object.entries(parsed.stats)) {
|
|
654
|
+
if (isStatEntry(entry))
|
|
655
|
+
cache.setStats(key, entry);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Loading what's already on disk is not a change worth writing back.
|
|
659
|
+
cache.changes = before;
|
|
660
|
+
}
|
|
661
|
+
/** Write `cache` to `file` (temp file + rename). Best effort: it is only a cache. */
|
|
662
|
+
export async function saveSkillScanCacheFile(cache, file) {
|
|
663
|
+
const data = {
|
|
664
|
+
version: SKILL_SCAN_RULES_VERSION,
|
|
665
|
+
verdicts: Object.fromEntries(cache.entries()),
|
|
666
|
+
stats: Object.fromEntries(cache.statEntries()),
|
|
667
|
+
};
|
|
668
|
+
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
669
|
+
try {
|
|
670
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
671
|
+
await fs.writeFile(tmp, JSON.stringify(data), "utf-8");
|
|
672
|
+
await fs.rename(tmp, file);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
export const defaultSkillScanCache = new SkillScanCache();
|
|
679
|
+
function scanInputs(inputs, cache) {
|
|
680
|
+
const key = hashInputs(inputs);
|
|
681
|
+
const cached = cache.get(key);
|
|
682
|
+
if (cached)
|
|
683
|
+
return cached;
|
|
684
|
+
let result = { ok: true };
|
|
685
|
+
for (const input of inputs) {
|
|
686
|
+
const finding = scanSkillText(input.content, input.file, input.kind);
|
|
687
|
+
if (finding) {
|
|
688
|
+
result = { ok: false, finding, hash: key };
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
cache.set(key, result);
|
|
693
|
+
return result;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Scan a directory-layout skill: `<root>/SKILL.md` (passed raw as read by the
|
|
697
|
+
* loader) plus every script in the folder, and every symlink in it.
|
|
698
|
+
*/
|
|
699
|
+
export async function scanSkillFolder(root, skillMd, cache = defaultSkillScanCache) {
|
|
700
|
+
let realRoot;
|
|
701
|
+
try {
|
|
702
|
+
realRoot = await fs.realpath(root);
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
realRoot = path.resolve(root);
|
|
706
|
+
}
|
|
707
|
+
const skillPath = path.join(root, "SKILL.md");
|
|
708
|
+
const statKey = `dir:${path.resolve(root)}`;
|
|
709
|
+
const known = cache.getStats(statKey);
|
|
710
|
+
// Warm path: nothing changed since the last scan → no reads, no hashing.
|
|
711
|
+
if (known && (await statsMatch(root, known.sigs)))
|
|
712
|
+
return known.verdict;
|
|
713
|
+
let skillSig = null;
|
|
714
|
+
try {
|
|
715
|
+
const stat = await fs.lstat(skillPath);
|
|
716
|
+
if (!stat.isSymbolicLink())
|
|
717
|
+
skillSig = sigOf("SKILL.md", stat);
|
|
718
|
+
if (stat.isSymbolicLink() && !isInside(realRoot, await realOrResolved(skillPath))) {
|
|
719
|
+
return {
|
|
720
|
+
ok: false,
|
|
721
|
+
finding: {
|
|
722
|
+
rule: "symlink-escape",
|
|
723
|
+
file: "SKILL.md",
|
|
724
|
+
line: 0,
|
|
725
|
+
reason: "SKILL.md is a symlink pointing outside the skill folder",
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// Missing SKILL.md is the loader's concern.
|
|
732
|
+
}
|
|
733
|
+
const { inputs, escape, sigs } = await collect(root, realRoot);
|
|
734
|
+
if (escape)
|
|
735
|
+
return { ok: false, finding: escape };
|
|
736
|
+
const verdict = scanInputs([{ file: "SKILL.md", kind: "markdown", content: skillMd }, ...inputs], cache);
|
|
737
|
+
if (sigs && skillSig)
|
|
738
|
+
cache.setStats(statKey, { sigs: [skillSig, ...sigs], verdict });
|
|
739
|
+
return verdict;
|
|
740
|
+
}
|
|
741
|
+
/** Scan a flat-layout skill (`<dir>/<name>.md`), refusing a symlink that leaves `dir`. */
|
|
742
|
+
export async function scanSkillFile(filePath, content, cache = defaultSkillScanCache) {
|
|
743
|
+
const file = path.basename(filePath);
|
|
744
|
+
const statKey = `file:${path.resolve(filePath)}`;
|
|
745
|
+
const known = cache.getStats(statKey);
|
|
746
|
+
if (known && (await statsMatch(path.dirname(filePath), known.sigs)))
|
|
747
|
+
return known.verdict;
|
|
748
|
+
let fileSig = null;
|
|
749
|
+
try {
|
|
750
|
+
const stat = await fs.lstat(filePath);
|
|
751
|
+
if (!stat.isSymbolicLink())
|
|
752
|
+
fileSig = sigOf(file, stat);
|
|
753
|
+
if (stat.isSymbolicLink()) {
|
|
754
|
+
const dir = await fs.realpath(path.dirname(filePath));
|
|
755
|
+
if (!isInside(dir, await realOrResolved(filePath))) {
|
|
756
|
+
return {
|
|
757
|
+
ok: false,
|
|
758
|
+
finding: {
|
|
759
|
+
rule: "symlink-escape",
|
|
760
|
+
file,
|
|
761
|
+
line: 0,
|
|
762
|
+
reason: `${file} is a symlink pointing outside the skills folder`,
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
// Unreadable metadata: content scan still applies.
|
|
770
|
+
}
|
|
771
|
+
const verdict = scanInputs([{ file, kind: "markdown", content }], cache);
|
|
772
|
+
if (fileSig)
|
|
773
|
+
cache.setStats(statKey, { sigs: [fileSig], verdict });
|
|
774
|
+
return verdict;
|
|
775
|
+
}
|
|
776
|
+
/** One plain line for logs and the user: which skill, and why. */
|
|
777
|
+
export function skillBlockMessage(name, finding) {
|
|
778
|
+
const where = finding.line > 0 ? `${finding.file}:${finding.line}` : finding.file;
|
|
779
|
+
return `Skill "${name}" not loaded: ${finding.reason} (${where}).`;
|
|
780
|
+
}
|
|
781
|
+
//# sourceMappingURL=skill-scan.js.map
|