@h0tp/shucky 0.1.0 → 0.4.5
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/CHANGELOG.md +139 -29
- package/LICENSE +21 -21
- package/NOTICE +24 -0
- package/README.md +216 -119
- package/SKILL.md +168 -124
- package/bin/shucky.js +13 -13
- package/config.json +28 -28
- package/lib/agents.js +163 -0
- package/lib/approvals.js +50 -50
- package/lib/archive.js +173 -0
- package/lib/cli.js +782 -118
- package/lib/config.js +52 -52
- package/lib/discover.js +143 -0
- package/lib/fetch.js +303 -0
- package/lib/find.js +162 -0
- package/lib/lock.js +119 -0
- package/lib/place.js +247 -0
- package/lib/registry.js +141 -0
- package/lib/report.js +53 -53
- package/lib/rules.js +162 -162
- package/lib/safeurl.js +139 -0
- package/lib/scan.js +148 -148
- package/lib/sources.js +311 -0
- package/package.json +43 -41
package/lib/scan.js
CHANGED
|
@@ -1,148 +1,148 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { RULES, SUSPICIOUS_BINARY_EXT, isProbablyBinary } = require('./rules');
|
|
6
|
-
const { loadApprovals, isApproved } = require('./approvals');
|
|
7
|
-
|
|
8
|
-
const MAX_READ_BYTES = 512 * 1024;
|
|
9
|
-
const SEVERITY_RANK = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
10
|
-
|
|
11
|
-
function severityRank(s) { return SEVERITY_RANK[s] || 0; }
|
|
12
|
-
|
|
13
|
-
function walk(dir, out) {
|
|
14
|
-
let entries;
|
|
15
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
16
|
-
catch (e) { return out; }
|
|
17
|
-
for (const e of entries) {
|
|
18
|
-
if (e.name === '.git' || e.name === 'node_modules') continue;
|
|
19
|
-
const full = path.join(dir, e.name);
|
|
20
|
-
if (e.isDirectory()) walk(full, out);
|
|
21
|
-
else if (e.isFile()) out.push(full);
|
|
22
|
-
}
|
|
23
|
-
return out;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function isTrusted(source, trustedSources) {
|
|
27
|
-
if (!source || !Array.isArray(trustedSources)) return false;
|
|
28
|
-
const owner = String(source).toLowerCase().split('/')[0];
|
|
29
|
-
return trustedSources.some(function (t) {
|
|
30
|
-
t = String(t).toLowerCase();
|
|
31
|
-
return owner === t || String(source).toLowerCase() === t;
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Apply rules to one file's lines.
|
|
36
|
-
// In Markdown, code-execution rules run only INSIDE fenced code blocks; prose is checked for
|
|
37
|
-
// prompt_injection only — so a doc that merely *mentions* "curl | sh" in a sentence isn't
|
|
38
|
-
// flagged, but a real command in a ``` block is. Non-Markdown files (scripts, etc.) get every
|
|
39
|
-
// rule on every line.
|
|
40
|
-
function scanLines(rel, lines, isMarkdown, config, findings) {
|
|
41
|
-
let inFence = false;
|
|
42
|
-
for (let i = 0; i < lines.length; i++) {
|
|
43
|
-
const line = lines[i];
|
|
44
|
-
if (isMarkdown && /^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
|
|
45
|
-
for (const rule of RULES) {
|
|
46
|
-
if (config.rules && config.rules[rule.id] === false) continue;
|
|
47
|
-
if (isMarkdown && !inFence && rule.id !== 'prompt_injection') continue;
|
|
48
|
-
for (const re of rule.patterns) {
|
|
49
|
-
if (re.test(line)) {
|
|
50
|
-
findings.push({
|
|
51
|
-
ruleId: rule.id, severity: rule.severity, file: rel, line: i + 1,
|
|
52
|
-
snippet: line.trim().slice(0, 160), why: rule.why
|
|
53
|
-
});
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Read files as text and apply rules. NEVER executes anything.
|
|
62
|
-
function scanTarget(targetPath, config) {
|
|
63
|
-
const stat = fs.statSync(targetPath);
|
|
64
|
-
const baseDir = stat.isDirectory() ? targetPath : path.dirname(targetPath);
|
|
65
|
-
const files = stat.isDirectory() ? walk(targetPath, []) : [targetPath];
|
|
66
|
-
|
|
67
|
-
const findings = [];
|
|
68
|
-
const fileInfos = [];
|
|
69
|
-
|
|
70
|
-
for (const f of files) {
|
|
71
|
-
const rel = path.relative(baseDir, f) || path.basename(f);
|
|
72
|
-
const ext = path.extname(f).toLowerCase();
|
|
73
|
-
let size = 0;
|
|
74
|
-
try { size = fs.statSync(f).size; } catch (e) { /* ignore */ }
|
|
75
|
-
|
|
76
|
-
let buf;
|
|
77
|
-
try { buf = fs.readFileSync(f); }
|
|
78
|
-
catch (e) { fileInfos.push({ path: rel, size: size, note: 'unreadable' }); continue; }
|
|
79
|
-
|
|
80
|
-
if (isProbablyBinary(buf)) {
|
|
81
|
-
fileInfos.push({ path: rel, size: size, binary: true });
|
|
82
|
-
if (SUSPICIOUS_BINARY_EXT.has(ext)) {
|
|
83
|
-
findings.push({
|
|
84
|
-
ruleId: 'obfuscation', severity: 'high', file: rel, line: 0,
|
|
85
|
-
snippet: '<binary ' + ext + '>',
|
|
86
|
-
why: 'Ships compiled/opaque executable code inside a skill.'
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (size > MAX_READ_BYTES) {
|
|
93
|
-
fileInfos.push({ path: rel, size: size, note: 'skipped (>512KB)' });
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
fileInfos.push({ path: rel, size: size });
|
|
98
|
-
const isMarkdown = ext === '.md' || ext === '.markdown';
|
|
99
|
-
scanLines(rel, buf.toString('utf8').split(/\r?\n/), isMarkdown, config, findings);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const trusted = isTrusted(config.source, config.trustedSources);
|
|
103
|
-
const relaxed = trusted && config.trustedSourcePolicy === 'relax';
|
|
104
|
-
|
|
105
|
-
const counts = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
106
|
-
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
107
|
-
|
|
108
|
-
// Severities that count toward the verdict (relax drops low/medium for trusted sources).
|
|
109
|
-
const counting = findings.filter(function (f) {
|
|
110
|
-
if (relaxed && (f.severity === 'low' || f.severity === 'medium')) return false;
|
|
111
|
-
return true;
|
|
112
|
-
}).map(function (f) { return f.severity; });
|
|
113
|
-
|
|
114
|
-
const failOn = config.failOn || ['high', 'critical'];
|
|
115
|
-
const warnOn = config.warnOn || ['medium'];
|
|
116
|
-
const hits = function (set) { return set.some(function (s) { return counting.indexOf(s) !== -1; }); };
|
|
117
|
-
|
|
118
|
-
let rawVerdict = 'pass';
|
|
119
|
-
if (hits(failOn)) rawVerdict = 'block';
|
|
120
|
-
else if (hits(warnOn)) rawVerdict = 'warn';
|
|
121
|
-
|
|
122
|
-
// Persistent override: an exact source@version approved earlier forces pass (a logged override).
|
|
123
|
-
let overriddenByApproval = null;
|
|
124
|
-
if (config.source && config.version) {
|
|
125
|
-
overriddenByApproval = isApproved(loadApprovals(config), config.source, config.version);
|
|
126
|
-
}
|
|
127
|
-
const verdict = overriddenByApproval ? 'pass' : rawVerdict;
|
|
128
|
-
|
|
129
|
-
findings.sort(function (a, b) { return severityRank(b.severity) - severityRank(a.severity); });
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
target: targetPath,
|
|
133
|
-
source: config.source || null,
|
|
134
|
-
version: config.version || null,
|
|
135
|
-
trusted: trusted,
|
|
136
|
-
relaxed: relaxed,
|
|
137
|
-
policy: config.policy,
|
|
138
|
-
files: fileInfos,
|
|
139
|
-
findings: findings,
|
|
140
|
-
counts: counts,
|
|
141
|
-
verdict: verdict,
|
|
142
|
-
rawVerdict: rawVerdict,
|
|
143
|
-
overriddenByApproval: overriddenByApproval,
|
|
144
|
-
requireAgentReview: config.requireAgentReview !== false
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
module.exports = { scanTarget, severityRank, isTrusted };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { RULES, SUSPICIOUS_BINARY_EXT, isProbablyBinary } = require('./rules');
|
|
6
|
+
const { loadApprovals, isApproved } = require('./approvals');
|
|
7
|
+
|
|
8
|
+
const MAX_READ_BYTES = 512 * 1024;
|
|
9
|
+
const SEVERITY_RANK = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
10
|
+
|
|
11
|
+
function severityRank(s) { return SEVERITY_RANK[s] || 0; }
|
|
12
|
+
|
|
13
|
+
function walk(dir, out) {
|
|
14
|
+
let entries;
|
|
15
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
16
|
+
catch (e) { return out; }
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
if (e.name === '.git' || e.name === 'node_modules') continue;
|
|
19
|
+
const full = path.join(dir, e.name);
|
|
20
|
+
if (e.isDirectory()) walk(full, out);
|
|
21
|
+
else if (e.isFile()) out.push(full);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isTrusted(source, trustedSources) {
|
|
27
|
+
if (!source || !Array.isArray(trustedSources)) return false;
|
|
28
|
+
const owner = String(source).toLowerCase().split('/')[0];
|
|
29
|
+
return trustedSources.some(function (t) {
|
|
30
|
+
t = String(t).toLowerCase();
|
|
31
|
+
return owner === t || String(source).toLowerCase() === t;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Apply rules to one file's lines.
|
|
36
|
+
// In Markdown, code-execution rules run only INSIDE fenced code blocks; prose is checked for
|
|
37
|
+
// prompt_injection only — so a doc that merely *mentions* "curl | sh" in a sentence isn't
|
|
38
|
+
// flagged, but a real command in a ``` block is. Non-Markdown files (scripts, etc.) get every
|
|
39
|
+
// rule on every line.
|
|
40
|
+
function scanLines(rel, lines, isMarkdown, config, findings) {
|
|
41
|
+
let inFence = false;
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
const line = lines[i];
|
|
44
|
+
if (isMarkdown && /^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
|
|
45
|
+
for (const rule of RULES) {
|
|
46
|
+
if (config.rules && config.rules[rule.id] === false) continue;
|
|
47
|
+
if (isMarkdown && !inFence && rule.id !== 'prompt_injection') continue;
|
|
48
|
+
for (const re of rule.patterns) {
|
|
49
|
+
if (re.test(line)) {
|
|
50
|
+
findings.push({
|
|
51
|
+
ruleId: rule.id, severity: rule.severity, file: rel, line: i + 1,
|
|
52
|
+
snippet: line.trim().slice(0, 160), why: rule.why
|
|
53
|
+
});
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read files as text and apply rules. NEVER executes anything.
|
|
62
|
+
function scanTarget(targetPath, config) {
|
|
63
|
+
const stat = fs.statSync(targetPath);
|
|
64
|
+
const baseDir = stat.isDirectory() ? targetPath : path.dirname(targetPath);
|
|
65
|
+
const files = stat.isDirectory() ? walk(targetPath, []) : [targetPath];
|
|
66
|
+
|
|
67
|
+
const findings = [];
|
|
68
|
+
const fileInfos = [];
|
|
69
|
+
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
const rel = path.relative(baseDir, f) || path.basename(f);
|
|
72
|
+
const ext = path.extname(f).toLowerCase();
|
|
73
|
+
let size = 0;
|
|
74
|
+
try { size = fs.statSync(f).size; } catch (e) { /* ignore */ }
|
|
75
|
+
|
|
76
|
+
let buf;
|
|
77
|
+
try { buf = fs.readFileSync(f); }
|
|
78
|
+
catch (e) { fileInfos.push({ path: rel, size: size, note: 'unreadable' }); continue; }
|
|
79
|
+
|
|
80
|
+
if (isProbablyBinary(buf)) {
|
|
81
|
+
fileInfos.push({ path: rel, size: size, binary: true });
|
|
82
|
+
if (SUSPICIOUS_BINARY_EXT.has(ext)) {
|
|
83
|
+
findings.push({
|
|
84
|
+
ruleId: 'obfuscation', severity: 'high', file: rel, line: 0,
|
|
85
|
+
snippet: '<binary ' + ext + '>',
|
|
86
|
+
why: 'Ships compiled/opaque executable code inside a skill.'
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (size > MAX_READ_BYTES) {
|
|
93
|
+
fileInfos.push({ path: rel, size: size, note: 'skipped (>512KB)' });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fileInfos.push({ path: rel, size: size });
|
|
98
|
+
const isMarkdown = ext === '.md' || ext === '.markdown';
|
|
99
|
+
scanLines(rel, buf.toString('utf8').split(/\r?\n/), isMarkdown, config, findings);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const trusted = isTrusted(config.source, config.trustedSources);
|
|
103
|
+
const relaxed = trusted && config.trustedSourcePolicy === 'relax';
|
|
104
|
+
|
|
105
|
+
const counts = { low: 0, medium: 0, high: 0, critical: 0 };
|
|
106
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
107
|
+
|
|
108
|
+
// Severities that count toward the verdict (relax drops low/medium for trusted sources).
|
|
109
|
+
const counting = findings.filter(function (f) {
|
|
110
|
+
if (relaxed && (f.severity === 'low' || f.severity === 'medium')) return false;
|
|
111
|
+
return true;
|
|
112
|
+
}).map(function (f) { return f.severity; });
|
|
113
|
+
|
|
114
|
+
const failOn = config.failOn || ['high', 'critical'];
|
|
115
|
+
const warnOn = config.warnOn || ['medium'];
|
|
116
|
+
const hits = function (set) { return set.some(function (s) { return counting.indexOf(s) !== -1; }); };
|
|
117
|
+
|
|
118
|
+
let rawVerdict = 'pass';
|
|
119
|
+
if (hits(failOn)) rawVerdict = 'block';
|
|
120
|
+
else if (hits(warnOn)) rawVerdict = 'warn';
|
|
121
|
+
|
|
122
|
+
// Persistent override: an exact source@version approved earlier forces pass (a logged override).
|
|
123
|
+
let overriddenByApproval = null;
|
|
124
|
+
if (config.source && config.version) {
|
|
125
|
+
overriddenByApproval = isApproved(loadApprovals(config), config.source, config.version);
|
|
126
|
+
}
|
|
127
|
+
const verdict = overriddenByApproval ? 'pass' : rawVerdict;
|
|
128
|
+
|
|
129
|
+
findings.sort(function (a, b) { return severityRank(b.severity) - severityRank(a.severity); });
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
target: targetPath,
|
|
133
|
+
source: config.source || null,
|
|
134
|
+
version: config.version || null,
|
|
135
|
+
trusted: trusted,
|
|
136
|
+
relaxed: relaxed,
|
|
137
|
+
policy: config.policy,
|
|
138
|
+
files: fileInfos,
|
|
139
|
+
findings: findings,
|
|
140
|
+
counts: counts,
|
|
141
|
+
verdict: verdict,
|
|
142
|
+
rawVerdict: rawVerdict,
|
|
143
|
+
overriddenByApproval: overriddenByApproval,
|
|
144
|
+
requireAgentReview: config.requireAgentReview !== false
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { scanTarget, severityRank, isTrusted };
|
package/lib/sources.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// shucky source resolver — parse a source string into a structured target.
|
|
4
|
+
// Portions ported from vercel-labs/skills `src/source-parser.ts` (MIT). See NOTICE.
|
|
5
|
+
// Pure string → struct: NO I/O, NO network. Trivially unit-testable.
|
|
6
|
+
//
|
|
7
|
+
// ParsedSource = { type, url, subpath?, ref?, skillFilter?, localPath? }
|
|
8
|
+
// type ∈ local | github | gitlab | git | well-known | rawfile | gist
|
|
9
|
+
// shucky widens the reference set with `rawfile` (a direct SKILL.md / .md URL,
|
|
10
|
+
// incl. github /blob/ and gitlab /-/raw/) and `gist`, so "from anywhere" is literally true.
|
|
11
|
+
|
|
12
|
+
const { isAbsolute, resolve } = require('path');
|
|
13
|
+
|
|
14
|
+
// Common shorthand → canonical source.
|
|
15
|
+
const SOURCE_ALIASES = {
|
|
16
|
+
'coinbase/agentWallet': 'coinbase/agentic-wallet-skills'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function parseOwnerRepo(ownerRepo) {
|
|
20
|
+
const m = String(ownerRepo).match(/^([^/]+)\/([^/]+)$/);
|
|
21
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Reject any subpath with a ".." segment (path-traversal guard, parse-time).
|
|
25
|
+
function sanitizeSubpath(subpath) {
|
|
26
|
+
const normalized = String(subpath).replace(/\\/g, '/');
|
|
27
|
+
for (const seg of normalized.split('/')) {
|
|
28
|
+
if (seg === '..') {
|
|
29
|
+
throw new Error('Unsafe subpath: "' + subpath + '" contains path-traversal ".." segments.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return subpath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isLocalPath(input) {
|
|
36
|
+
return (
|
|
37
|
+
isAbsolute(input) ||
|
|
38
|
+
input.startsWith('./') ||
|
|
39
|
+
input.startsWith('../') ||
|
|
40
|
+
input === '.' ||
|
|
41
|
+
input === '..' ||
|
|
42
|
+
/^[a-zA-Z]:[/\\]/.test(input) // Windows C:\ , D:/ , …
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// .tar.gz / .tgz / .zip → archive (tested on the path portion, ignoring #frag / ?query).
|
|
47
|
+
function archiveFormat(spec) {
|
|
48
|
+
const p = String(spec).split('#')[0].split('?')[0];
|
|
49
|
+
if (/\.zip$/i.test(p)) return 'zip';
|
|
50
|
+
if (/\.(tar\.gz|tgz)$/i.test(p)) return 'tar.gz';
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function decodeFragmentValue(value) {
|
|
55
|
+
try { return decodeURIComponent(value); }
|
|
56
|
+
catch (e) { return value; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Only treat a trailing #fragment as a git ref for git-shaped sources (so a
|
|
60
|
+
// generic well-known URL keeps its fragment).
|
|
61
|
+
function looksLikeGitSource(input) {
|
|
62
|
+
if (input.startsWith('github:') || input.startsWith('gitlab:') || input.startsWith('git@')) return true;
|
|
63
|
+
if (/^ssh:\/\/.+\.git(?:$|[/?])/i.test(input)) return true;
|
|
64
|
+
if (input.startsWith('http://') || input.startsWith('https://')) {
|
|
65
|
+
try {
|
|
66
|
+
const u = new URL(input);
|
|
67
|
+
const p = u.pathname;
|
|
68
|
+
if (u.hostname === 'github.com') return /^\/[^/]+\/[^/]+(?:\.git)?(?:\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(p);
|
|
69
|
+
if (u.hostname === 'gitlab.com') return /^\/.+?\/[^/]+(?:\.git)?(?:\/-\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(p);
|
|
70
|
+
} catch (e) { /* fall through */ }
|
|
71
|
+
}
|
|
72
|
+
if (/^https?:\/\/.+\.git(?:$|[/?])/i.test(input)) return true;
|
|
73
|
+
return (
|
|
74
|
+
!input.includes(':') &&
|
|
75
|
+
!input.startsWith('.') &&
|
|
76
|
+
!input.startsWith('/') &&
|
|
77
|
+
/^([^/]+)\/([^/]+)(?:\/(.+)|@(.+))?$/.test(input)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseFragmentRef(input) {
|
|
82
|
+
const hashIndex = input.indexOf('#');
|
|
83
|
+
if (hashIndex < 0) return { inputWithoutFragment: input };
|
|
84
|
+
const inputWithoutFragment = input.slice(0, hashIndex);
|
|
85
|
+
const fragment = input.slice(hashIndex + 1);
|
|
86
|
+
if (!fragment || !looksLikeGitSource(inputWithoutFragment)) return { inputWithoutFragment: input };
|
|
87
|
+
const atIndex = fragment.indexOf('@');
|
|
88
|
+
if (atIndex === -1) return { inputWithoutFragment: inputWithoutFragment, ref: decodeFragmentValue(fragment) };
|
|
89
|
+
const ref = fragment.slice(0, atIndex);
|
|
90
|
+
const skillFilter = fragment.slice(atIndex + 1);
|
|
91
|
+
return {
|
|
92
|
+
inputWithoutFragment: inputWithoutFragment,
|
|
93
|
+
ref: ref ? decodeFragmentValue(ref) : undefined,
|
|
94
|
+
skillFilter: skillFilter ? decodeFragmentValue(skillFilter) : undefined
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function appendFragmentRef(input, ref, skillFilter) {
|
|
99
|
+
if (!ref) return input;
|
|
100
|
+
return input + '#' + ref + (skillFilter ? '@' + skillFilter : '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Any HTTP(S) URL that is not a known git host and not a .git repo → well-known discovery.
|
|
104
|
+
function isWellKnownUrl(input) {
|
|
105
|
+
if (!input.startsWith('http://') && !input.startsWith('https://')) return false;
|
|
106
|
+
try {
|
|
107
|
+
const u = new URL(input);
|
|
108
|
+
const excluded = [
|
|
109
|
+
'github.com', 'gitlab.com',
|
|
110
|
+
'raw.githubusercontent.com', 'gist.github.com', 'gist.githubusercontent.com'
|
|
111
|
+
];
|
|
112
|
+
if (excluded.indexOf(u.hostname.toLowerCase()) !== -1) return false;
|
|
113
|
+
if (input.endsWith('.git')) return false;
|
|
114
|
+
return true;
|
|
115
|
+
} catch (e) { return false; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// shucky extension: classify direct-file and gist http(s) sources BEFORE the loose
|
|
119
|
+
// github/gitlab regexes (which would otherwise mis-match e.g. gist.github.com).
|
|
120
|
+
function classifyHttpUrl(input, fragmentRef) {
|
|
121
|
+
let u;
|
|
122
|
+
try { u = new URL(input); } catch (e) { return null; }
|
|
123
|
+
const host = u.hostname.toLowerCase();
|
|
124
|
+
const path = u.pathname;
|
|
125
|
+
|
|
126
|
+
if (host === 'gist.github.com') {
|
|
127
|
+
const parts = path.split('/').filter(Boolean);
|
|
128
|
+
const id = (parts.length >= 2 ? parts[1] : parts[0]) || '';
|
|
129
|
+
const cleanId = id.replace(/\.git$/, '');
|
|
130
|
+
if (cleanId) {
|
|
131
|
+
const out = { type: 'gist', url: 'https://gist.github.com/' + cleanId + '.git' };
|
|
132
|
+
if (fragmentRef) out.ref = fragmentRef;
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (host === 'raw.githubusercontent.com' || host === 'gist.githubusercontent.com') {
|
|
138
|
+
return { type: 'rawfile', url: input };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (host === 'github.com') {
|
|
142
|
+
// /blob/<ref>/<path> or /raw/<ref>/<path> → fetch the raw file directly.
|
|
143
|
+
const m = path.match(/^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/([^/]+)\/(.+)$/);
|
|
144
|
+
if (m) {
|
|
145
|
+
const owner = m[1], repo = m[2].replace(/\.git$/, ''), ref = m[3], p = m[4];
|
|
146
|
+
return { type: 'rawfile', url: 'https://raw.githubusercontent.com/' + owner + '/' + repo + '/' + ref + '/' + p };
|
|
147
|
+
}
|
|
148
|
+
return null; // repo / tree URLs handled by the git logic below
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (host === 'gitlab.com') {
|
|
152
|
+
if (path.indexOf('/-/raw/') !== -1) return { type: 'rawfile', url: input }; // gitlab raw is directly fetchable
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Any other host whose path points straight at a markdown / SKILL file → rawfile.
|
|
157
|
+
if (/\/SKILL\.md$/i.test(path) || /\.md$/i.test(path)) {
|
|
158
|
+
return { type: 'rawfile', url: input };
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseSource(input) {
|
|
164
|
+
input = String(input == null ? '' : input).trim();
|
|
165
|
+
if (!input) throw new Error('empty source');
|
|
166
|
+
|
|
167
|
+
// Local path: absolute, relative, or current dir.
|
|
168
|
+
if (isLocalPath(input)) {
|
|
169
|
+
const resolved = resolve(input);
|
|
170
|
+
const lfmt = archiveFormat(input);
|
|
171
|
+
if (lfmt) return { type: 'archive', url: resolved, localPath: resolved, archiveFormat: lfmt };
|
|
172
|
+
return { type: 'local', url: resolved, localPath: resolved };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const fr = parseFragmentRef(input);
|
|
176
|
+
let body = fr.inputWithoutFragment;
|
|
177
|
+
const fragmentRef = fr.ref;
|
|
178
|
+
const fragmentSkillFilter = fr.skillFilter;
|
|
179
|
+
|
|
180
|
+
if (SOURCE_ALIASES[body]) body = SOURCE_ALIASES[body];
|
|
181
|
+
|
|
182
|
+
// Prefix shorthands.
|
|
183
|
+
const ghPrefix = body.match(/^github:(.+)$/);
|
|
184
|
+
if (ghPrefix) return parseSource(appendFragmentRef(ghPrefix[1], fragmentRef, fragmentSkillFilter));
|
|
185
|
+
const glPrefix = body.match(/^gitlab:(.+)$/);
|
|
186
|
+
if (glPrefix) return parseSource(appendFragmentRef('https://gitlab.com/' + glPrefix[1], fragmentRef, fragmentSkillFilter));
|
|
187
|
+
const gistPrefix = body.match(/^gist:(.+)$/);
|
|
188
|
+
if (gistPrefix) {
|
|
189
|
+
const out = { type: 'gist', url: 'https://gist.github.com/' + gistPrefix[1].replace(/\.git$/, '') + '.git' };
|
|
190
|
+
if (fragmentRef) out.ref = fragmentRef;
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// shucky: archive + direct-file + gist classification before the loose git regexes.
|
|
195
|
+
if (body.startsWith('http://') || body.startsWith('https://')) {
|
|
196
|
+
const afmt = archiveFormat(body);
|
|
197
|
+
if (afmt) return { type: 'archive', url: body, archiveFormat: afmt };
|
|
198
|
+
const c = classifyHttpUrl(body, fragmentRef);
|
|
199
|
+
if (c) return c;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// GitHub URL with path: …/github.com/owner/repo/tree/<ref>/<subpath>
|
|
203
|
+
let m = body.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/);
|
|
204
|
+
if (m) {
|
|
205
|
+
return {
|
|
206
|
+
type: 'github',
|
|
207
|
+
url: 'https://github.com/' + m[1] + '/' + m[2] + '.git',
|
|
208
|
+
ref: m[3] || fragmentRef,
|
|
209
|
+
subpath: sanitizeSubpath(m[4])
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// GitHub URL, branch only: …/github.com/owner/repo/tree/<ref>
|
|
213
|
+
m = body.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)$/);
|
|
214
|
+
if (m) {
|
|
215
|
+
return { type: 'github', url: 'https://github.com/' + m[1] + '/' + m[2] + '.git', ref: m[3] || fragmentRef };
|
|
216
|
+
}
|
|
217
|
+
// GitHub URL: …/github.com/owner/repo
|
|
218
|
+
m = body.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
219
|
+
if (m) {
|
|
220
|
+
const out = { type: 'github', url: 'https://github.com/' + m[1] + '/' + m[2].replace(/\.git$/, '') + '.git' };
|
|
221
|
+
if (fragmentRef) out.ref = fragmentRef;
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// GitLab URL with path (any instance): proto://host/<repoPath>/-/tree/<ref>/<subpath>
|
|
226
|
+
m = body.match(/^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)\/(.+)/);
|
|
227
|
+
if (m && m[2] !== 'github.com' && m[3]) {
|
|
228
|
+
return {
|
|
229
|
+
type: 'gitlab',
|
|
230
|
+
url: m[1] + '://' + m[2] + '/' + m[3].replace(/\.git$/, '') + '.git',
|
|
231
|
+
ref: m[4] || fragmentRef,
|
|
232
|
+
subpath: sanitizeSubpath(m[5])
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// GitLab URL, branch only: proto://host/<repoPath>/-/tree/<ref>
|
|
236
|
+
m = body.match(/^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)$/);
|
|
237
|
+
if (m && m[2] !== 'github.com' && m[3]) {
|
|
238
|
+
return { type: 'gitlab', url: m[1] + '://' + m[2] + '/' + m[3].replace(/\.git$/, '') + '.git', ref: m[4] || fragmentRef };
|
|
239
|
+
}
|
|
240
|
+
// gitlab.com URL (official host only): gitlab.com/owner/repo or group/subgroup/repo
|
|
241
|
+
m = body.match(/gitlab\.com\/(.+?)(?:\.git)?\/?$/);
|
|
242
|
+
if (m && m[1].indexOf('/') !== -1) {
|
|
243
|
+
const out = { type: 'gitlab', url: 'https://gitlab.com/' + m[1] + '.git' };
|
|
244
|
+
if (fragmentRef) out.ref = fragmentRef;
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// owner/repo@skill-name
|
|
249
|
+
m = body.match(/^([^/]+)\/([^/@]+)@(.+)$/);
|
|
250
|
+
if (m && body.indexOf(':') === -1 && !body.startsWith('.') && !body.startsWith('/')) {
|
|
251
|
+
const out = { type: 'github', url: 'https://github.com/' + m[1] + '/' + m[2] + '.git', skillFilter: fragmentSkillFilter || m[3] };
|
|
252
|
+
if (fragmentRef) out.ref = fragmentRef;
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
// owner/repo or owner/repo/sub/path
|
|
256
|
+
m = body.match(/^([^/]+)\/([^/]+)(?:\/(.+?))?\/?$/);
|
|
257
|
+
if (m && body.indexOf(':') === -1 && !body.startsWith('.') && !body.startsWith('/')) {
|
|
258
|
+
const out = { type: 'github', url: 'https://github.com/' + m[1] + '/' + m[2] + '.git' };
|
|
259
|
+
if (fragmentRef) out.ref = fragmentRef;
|
|
260
|
+
if (m[3]) out.subpath = sanitizeSubpath(m[3]);
|
|
261
|
+
if (fragmentSkillFilter) out.skillFilter = fragmentSkillFilter;
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Arbitrary HTTP(S) host (not git) → well-known discovery.
|
|
266
|
+
if (isWellKnownUrl(body)) {
|
|
267
|
+
return { type: 'well-known', url: body };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Fallback: a direct git URL (git@…, ssh://…, https://….git).
|
|
271
|
+
const out = { type: 'git', url: body };
|
|
272
|
+
if (fragmentRef) out.ref = fragmentRef;
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Normalised owner/repo (lowercased input form) for trust-matching + lock provenance.
|
|
277
|
+
// Returns null for local/rawfile/well-known (no owner/repo identity).
|
|
278
|
+
function getOwnerRepo(parsed) {
|
|
279
|
+
if (!parsed || parsed.type === 'local') return null;
|
|
280
|
+
const url = parsed.url || '';
|
|
281
|
+
|
|
282
|
+
const ssh = url.match(/^git@[^:]+:(.+)$/);
|
|
283
|
+
if (ssh) {
|
|
284
|
+
const p = ssh[1].replace(/\.git$/, '');
|
|
285
|
+
return p.indexOf('/') !== -1 ? p : null;
|
|
286
|
+
}
|
|
287
|
+
if (url.startsWith('ssh://')) {
|
|
288
|
+
try {
|
|
289
|
+
const u = new URL(url);
|
|
290
|
+
const p = u.pathname.slice(1).replace(/\.git$/, '');
|
|
291
|
+
return p.indexOf('/') !== -1 ? p : null;
|
|
292
|
+
} catch (e) { return null; }
|
|
293
|
+
}
|
|
294
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) return null;
|
|
295
|
+
try {
|
|
296
|
+
const u = new URL(url);
|
|
297
|
+
// rawfile / well-known have no stable owner/repo identity for trust.
|
|
298
|
+
if (parsed.type === 'rawfile' || parsed.type === 'well-known' || parsed.type === 'archive') return null;
|
|
299
|
+
const p = u.pathname.slice(1).replace(/\.git$/, '');
|
|
300
|
+
return p.indexOf('/') !== -1 ? p : null;
|
|
301
|
+
} catch (e) { return null; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
parseSource,
|
|
306
|
+
getOwnerRepo,
|
|
307
|
+
parseOwnerRepo,
|
|
308
|
+
sanitizeSubpath,
|
|
309
|
+
isLocalPath,
|
|
310
|
+
isWellKnownUrl
|
|
311
|
+
};
|