@oomerevren/tryforge 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +107 -13
  2. package/README.md +172 -232
  3. package/dist/cli/src/adapters/agents-md.js +24 -0
  4. package/dist/cli/src/adapters/base.js +99 -0
  5. package/dist/cli/src/adapters/claude.js +43 -14
  6. package/dist/cli/src/adapters/codex.js +18 -17
  7. package/dist/cli/src/adapters/cursor.js +44 -30
  8. package/dist/cli/src/adapters/dsh.js +28 -30
  9. package/dist/cli/src/adapters/generic.js +10 -7
  10. package/dist/cli/src/adapters/opencode.js +23 -23
  11. package/dist/cli/src/adapters/types.js +12 -9
  12. package/dist/cli/src/adapters/windsurf.js +48 -28
  13. package/dist/cli/src/commands/add-external.js +111 -0
  14. package/dist/cli/src/commands/audit.js +68 -30
  15. package/dist/cli/src/commands/init.js +2 -2
  16. package/dist/cli/src/commands/install.js +90 -15
  17. package/dist/cli/src/commands/pack.js +119 -0
  18. package/dist/cli/src/commands/sync.js +181 -0
  19. package/dist/cli/src/commands/test.js +72 -0
  20. package/dist/cli/src/commands/tui.js +191 -0
  21. package/dist/cli/src/commands/update.js +4 -4
  22. package/dist/cli/src/commands/verify.js +62 -0
  23. package/dist/cli/src/core/fsutil.js +37 -0
  24. package/dist/cli/src/core/installer.js +9 -9
  25. package/dist/cli/src/core/lock.js +97 -10
  26. package/dist/cli/src/core/merge.js +108 -0
  27. package/dist/cli/src/core/plugin.js +2 -2
  28. package/dist/cli/src/core/project.js +136 -4
  29. package/dist/cli/src/core/registry.js +9 -9
  30. package/dist/cli/src/core/scan.js +159 -0
  31. package/dist/cli/src/core/semver.js +7 -7
  32. package/dist/cli/src/core/sign.js +2 -2
  33. package/dist/cli/src/core/sources.js +171 -0
  34. package/dist/cli/src/index.js +83 -16
  35. package/dist/index.cjs +359 -0
  36. package/dist/scripts/build-registry.js +2 -4
  37. package/dist/scripts/publish-verified.js +17 -9
  38. package/dist/scripts/seed-registry-13lite.js +5 -5
  39. package/dist/scripts/seed-registry.js +4 -4
  40. package/dist/scripts/verify-npm-mcps.js +6 -4
  41. package/package.json +15 -6
@@ -1,9 +1,35 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { join, resolve } from "path";
3
3
  import { parse } from "smol-toml";
4
+ import { loadPackageDetail } from "./registry.js";
5
+ /** Build a lock entry with pinned integrity (source URL + sha256). */
6
+ export function lockEntryFor(name, version, type, meta, source = "registry") {
7
+ return {
8
+ name,
9
+ version,
10
+ type,
11
+ ...(meta.tarball ? { tarball: meta.tarball } : {}),
12
+ ...(meta.sha256 ? { sha256: meta.sha256 } : {}),
13
+ source,
14
+ };
15
+ }
4
16
  export function lockPath(cwd = process.cwd()) {
5
17
  return join(resolve(cwd), "forge.lock");
6
18
  }
19
+ function toEntry(x) {
20
+ const entry = {
21
+ name: String(x.name),
22
+ version: String(x.version),
23
+ type: String(x.type ?? "skill"),
24
+ };
25
+ if (typeof x.tarball === "string" && x.tarball.length > 0)
26
+ entry.tarball = x.tarball;
27
+ if (typeof x.sha256 === "string" && x.sha256.length > 0)
28
+ entry.sha256 = x.sha256;
29
+ if (typeof x.source === "string" && x.source.length > 0)
30
+ entry.source = x.source;
31
+ return entry;
32
+ }
7
33
  export function readLock(cwd = process.cwd()) {
8
34
  const p = lockPath(cwd);
9
35
  if (!existsSync(p))
@@ -14,28 +40,89 @@ export function readLock(cwd = process.cwd()) {
14
40
  const pkgs = parsed.packages;
15
41
  if (!Array.isArray(pkgs))
16
42
  return null;
17
- const packages = pkgs.map((x) => ({
18
- name: String(x.name),
19
- version: String(x.version),
20
- type: String(x.type ?? "skill"),
21
- }));
43
+ const packages = pkgs.map(toEntry);
22
44
  return { packages };
23
45
  }
24
46
  catch {
25
47
  return null;
26
48
  }
27
49
  }
50
+ function escapeTomlString(s) {
51
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
52
+ }
53
+ /** Deterministic writer: entries sorted by name, fixed field order, LF only. */
28
54
  export function writeLock(entries, cwd = process.cwd()) {
29
55
  const p = lockPath(cwd);
30
56
  // smol-toml stringify doesn't support array-of-tables well via object; craft manually
31
57
  // Use [[packages]] TOML array of tables
32
- const lines = ['# Generated by Forge — do not edit manually', ''];
33
- for (const e of entries) {
58
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
59
+ const lines = ["# Generated by Forge — do not edit manually", ""];
60
+ for (const e of sorted) {
34
61
  lines.push("[[packages]]");
35
- lines.push(`name = "${e.name}"`);
36
- lines.push(`version = "${e.version}"`);
37
- lines.push(`type = "${e.type}"`);
62
+ lines.push(`name = "${escapeTomlString(e.name)}"`);
63
+ lines.push(`version = "${escapeTomlString(e.version)}"`);
64
+ lines.push(`type = "${escapeTomlString(e.type)}"`);
65
+ if (e.tarball)
66
+ lines.push(`tarball = "${escapeTomlString(e.tarball)}"`);
67
+ if (e.sha256)
68
+ lines.push(`sha256 = "${escapeTomlString(e.sha256)}"`);
69
+ if (e.source)
70
+ lines.push(`source = "${escapeTomlString(e.source)}"`);
38
71
  lines.push("");
39
72
  }
40
73
  writeFileSync(p, lines.join("\n"));
41
74
  }
75
+ /**
76
+ * Security barrier for --frozen installs. For every locked entry:
77
+ * - the locked version must still exist in the registry (else "yanked")
78
+ * - a locked real sha256 must equal the registry sha256 (else "hash-mismatch")
79
+ * - a placeholder/mock sha256 is reported as "unverified" (fatal unless
80
+ * the caller explicitly allows mock via opts.allowMock)
81
+ * Returns the issue list (empty = lock is trustworthy). Never throws on
82
+ * registry I/O — transport failures surface as yanked with the cause inline.
83
+ */
84
+ export async function verifyLockIntegrity(lock, opts = {}) {
85
+ const issues = [];
86
+ for (const entry of lock.packages) {
87
+ let detail;
88
+ try {
89
+ detail = await loadPackageDetail(entry.name);
90
+ }
91
+ catch (e) {
92
+ issues.push({
93
+ name: entry.name,
94
+ kind: "yanked",
95
+ message: `${entry.name}: registry read failed (${e.message}) — refusing frozen install`,
96
+ });
97
+ continue;
98
+ }
99
+ const meta = detail.versions[entry.version];
100
+ if (!meta) {
101
+ issues.push({
102
+ name: entry.name,
103
+ kind: "yanked",
104
+ message: `${entry.name}@${entry.version} is yanked (version missing from registry) — refusing frozen install`,
105
+ });
106
+ continue;
107
+ }
108
+ const lockedSha = entry.sha256 ?? "";
109
+ const registrySha = meta.sha256 ?? "";
110
+ const lockedIsPlaceholder = lockedSha.startsWith("placeholder") || lockedSha === "";
111
+ if (lockedIsPlaceholder && !opts.allowMock) {
112
+ issues.push({
113
+ name: entry.name,
114
+ kind: "unverified",
115
+ message: `${entry.name}@${entry.version} has no pinned integrity hash — refusing frozen install (re-run without --frozen, or pass --mock to allow mock content)`,
116
+ });
117
+ continue;
118
+ }
119
+ if (!lockedIsPlaceholder && registrySha !== "" && lockedSha !== registrySha) {
120
+ issues.push({
121
+ name: entry.name,
122
+ kind: "hash-mismatch",
123
+ message: `${entry.name}@${entry.version} integrity mismatch: lock=${lockedSha.slice(0, 12)}… registry=${registrySha.slice(0, 12)}… — refusing frozen install`,
124
+ });
125
+ }
126
+ }
127
+ return issues;
128
+ }
@@ -0,0 +1,108 @@
1
+ // cli/src/core/merge.ts — Non-destructive 3-way merge engine (Phase 2).
2
+ //
3
+ // Forge-managed sections inside user-owned text files (CLAUDE.md, AGENTS.md,
4
+ // .windsurfrules, .cursor/rules/*.mdc) are delimited by markers:
5
+ //
6
+ // <!-- FORGE:START id="<slug>" version="<version>" -->
7
+ // ...skill instructions...
8
+ // <!-- FORGE:END id="<slug>" -->
9
+ //
10
+ // Rules:
11
+ // - User content outside markers is NEVER touched (byte-preserved).
12
+ // - Re-install / version bump replaces only the marked block (idempotent).
13
+ // - Uninstall removes only the marked block; if the file is blank afterwards
14
+ // AND forge created it, the file is deleted (no litter).
15
+ // - Missing file is created with just the block.
16
+ // - Marker ids are restricted to [a-z0-9-/_.@] to block marker injection.
17
+ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "fs";
18
+ import { dirname } from "path";
19
+ const ID_RE = /^[a-z0-9\-/_.@]+$/i;
20
+ export function forgeStartMarker(id, version) {
21
+ assertSafeId(id);
22
+ return `<!-- FORGE:START id="${id}" version="${version}" -->`;
23
+ }
24
+ export function forgeEndMarker(id) {
25
+ assertSafeId(id);
26
+ return `<!-- FORGE:END id="${id}" -->`;
27
+ }
28
+ function assertSafeId(id) {
29
+ if (!ID_RE.test(id) || id.includes("-->") || id.includes("\n")) {
30
+ throw new Error(`[forge] invalid forge block id "${id}" — marker injection refused`);
31
+ }
32
+ }
33
+ function blockPattern(id) {
34
+ assertSafeId(id);
35
+ const esc = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+ // Matches a full block including a single trailing newline when present.
37
+ return new RegExp(`<!-- FORGE:START id="${esc}" version="[^"]*" -->\\r?\\n[\\s\\S]*?<!-- FORGE:END id="${esc}" -->\\r?\\n?`);
38
+ }
39
+ function ensureParentDir(filePath) {
40
+ const dir = dirname(filePath);
41
+ if (!existsSync(dir))
42
+ mkdirSync(dir, { recursive: true });
43
+ }
44
+ /** Read a forge-managed block body for an id, or null when absent. */
45
+ export function readForgeBlock(filePath, id) {
46
+ if (!existsSync(filePath))
47
+ return null;
48
+ const raw = readFileSync(filePath, "utf-8");
49
+ const m = raw.match(blockPattern(id));
50
+ if (!m)
51
+ return null;
52
+ const lines = m[0].split("\n");
53
+ // strip first (START) and last (END, possibly with trailing "") lines
54
+ return lines.slice(1, lines[lines.length - 1] === "" ? -2 : -1).join("\n");
55
+ }
56
+ /**
57
+ * Insert or replace the forge block for `id`. Returns "created" | "updated" |
58
+ * "unchanged" (byte-identical block already present — no write performed).
59
+ */
60
+ export function upsertForgeBlock(filePath, id, version, body) {
61
+ assertSafeId(id);
62
+ const cleanBody = body.replace(/\r\n/g, "\n").replace(/\n+$/, "") + "\n";
63
+ const block = `${forgeStartMarker(id, version)}\n${cleanBody}${forgeEndMarker(id)}\n`;
64
+ if (!existsSync(filePath)) {
65
+ ensureParentDir(filePath);
66
+ writeFileSync(filePath, block);
67
+ return "created";
68
+ }
69
+ const raw = readFileSync(filePath, "utf-8");
70
+ const pattern = blockPattern(id);
71
+ if (pattern.test(raw)) {
72
+ const next = raw.replace(pattern, block);
73
+ if (next === raw)
74
+ return "unchanged";
75
+ writeFileSync(filePath, next);
76
+ return "updated";
77
+ }
78
+ const sep = raw.length > 0 && !raw.endsWith("\n") ? "\n" : "";
79
+ const gap = raw.length > 0 && !raw.endsWith("\n\n") ? "\n" : "";
80
+ writeFileSync(filePath, `${raw}${sep}${gap}${block}`);
81
+ return "updated";
82
+ }
83
+ /**
84
+ * Remove the forge block for `id`. Returns true when a block was removed.
85
+ * When the file holds nothing but whitespace afterwards AND `deleteIfEmpty`
86
+ * is set (forge-created files), the file itself is deleted.
87
+ */
88
+ export function removeForgeBlock(filePath, id, opts = {}) {
89
+ if (!existsSync(filePath))
90
+ return false;
91
+ const raw = readFileSync(filePath, "utf-8");
92
+ const pattern = blockPattern(id);
93
+ if (!pattern.test(raw))
94
+ return false;
95
+ const next = raw.replace(pattern, "").replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n");
96
+ if (next.trim().length === 0 && opts.deleteIfEmpty !== false) {
97
+ rmSync(filePath, { force: true });
98
+ return true;
99
+ }
100
+ writeFileSync(filePath, next);
101
+ return true;
102
+ }
103
+ /** True when the file contains a forge block for `id`. */
104
+ export function hasForgeBlock(filePath, id) {
105
+ if (!existsSync(filePath))
106
+ return false;
107
+ return blockPattern(id).test(readFileSync(filePath, "utf-8"));
108
+ }
@@ -1,5 +1,5 @@
1
- // cli/src/core/plugin.ts — Epoch 1e: adapter plugin sistemi
2
- // Adapter'ları CLI çekirdeğinden ayırarak dinamik yükleme
1
+ // cli/src/core/plugin.ts — Epoch 1e: adapter plugin system
2
+ // Dynamically load adapters, decoupled from the CLI core
3
3
  import { existsSync, readdirSync } from "fs";
4
4
  import { join } from "path";
5
5
  /**
@@ -15,14 +15,14 @@ export function loadProjectToml(path) {
15
15
  raw = readFileSync(path, "utf-8");
16
16
  }
17
17
  catch (e) {
18
- throw new Error(`Cannot read ${path}: ${e.message}`);
18
+ throw new Error(`Cannot read ${path}: ${e.message}`, { cause: e });
19
19
  }
20
20
  let parsed;
21
21
  try {
22
22
  parsed = parse(raw);
23
23
  }
24
24
  catch (e) {
25
- throw new Error(`${path}: invalid TOML — ${e.message}`);
25
+ throw new Error(`${path}: invalid TOML — ${e.message}`, { cause: e });
26
26
  }
27
27
  // detect author [package] file (not a project install target)
28
28
  if (parsed.package && !parsed.dependencies && !parsed.project) {
@@ -41,9 +41,125 @@ export function loadProjectToml(path) {
41
41
  }
42
42
  const forge = parsed.forge;
43
43
  const project = parsed.project;
44
- return { project, dependencies, forge, package: parsed.package };
44
+ // [agents.<role>] shared agent roles with model + system prompt
45
+ let agents;
46
+ if (parsed.agents !== undefined) {
47
+ if (typeof parsed.agents !== "object" || Array.isArray(parsed.agents)) {
48
+ throw new Error(`${path}: [agents] must be a table of roles`);
49
+ }
50
+ agents = {};
51
+ for (const [role, def] of Object.entries(parsed.agents)) {
52
+ if (typeof def !== "object" || def === null || Array.isArray(def)) {
53
+ throw new Error(`${path}: [agents.${role}] must be a table`);
54
+ }
55
+ const d = def;
56
+ if (d.model !== undefined && typeof d.model !== "string") {
57
+ throw new Error(`${path}: [agents.${role}].model must be a string`);
58
+ }
59
+ if (d.system_prompt !== undefined && typeof d.system_prompt !== "string") {
60
+ throw new Error(`${path}: [agents.${role}].system_prompt must be a string`);
61
+ }
62
+ agents[role] = {
63
+ ...(typeof d.model === "string" ? { model: d.model } : {}),
64
+ ...(typeof d.system_prompt === "string" ? { system_prompt: d.system_prompt } : {}),
65
+ };
66
+ }
67
+ }
68
+ // [skills] — name = "version" | { version, source, ref }
69
+ let skills;
70
+ if (parsed.skills !== undefined) {
71
+ if (typeof parsed.skills !== "object" || Array.isArray(parsed.skills)) {
72
+ throw new Error(`${path}: [skills] must be a table`);
73
+ }
74
+ skills = {};
75
+ for (const [skillName, def] of Object.entries(parsed.skills)) {
76
+ if (typeof def === "string") {
77
+ skills[skillName] = { version: def };
78
+ }
79
+ else if (typeof def === "object" && def !== null && !Array.isArray(def)) {
80
+ const d = def;
81
+ const ref = {};
82
+ if (d.version !== undefined) {
83
+ if (typeof d.version !== "string")
84
+ throw new Error(`${path}: [skills.${skillName}].version must be a string`);
85
+ ref.version = d.version;
86
+ }
87
+ if (d.source !== undefined) {
88
+ if (typeof d.source !== "string")
89
+ throw new Error(`${path}: [skills.${skillName}].source must be a string`);
90
+ ref.source = d.source;
91
+ }
92
+ if (d.ref !== undefined) {
93
+ if (typeof d.ref !== "string")
94
+ throw new Error(`${path}: [skills.${skillName}].ref must be a string`);
95
+ ref.ref = d.ref;
96
+ }
97
+ skills[skillName] = ref;
98
+ }
99
+ else {
100
+ throw new Error(`${path}: [skills.${skillName}] must be a version string or { version, source, ref }`);
101
+ }
102
+ }
103
+ }
104
+ // [mcp.servers.<name>] — MCP server definitions
105
+ let mcp;
106
+ const rawMcp = parsed.mcp;
107
+ if (rawMcp !== undefined) {
108
+ if (typeof rawMcp !== "object" || Array.isArray(rawMcp)) {
109
+ throw new Error(`${path}: [mcp] must be a table`);
110
+ }
111
+ const servers = {};
112
+ const rawServers = (rawMcp.servers ?? {});
113
+ if (typeof rawServers !== "object" || Array.isArray(rawServers)) {
114
+ throw new Error(`${path}: [mcp.servers] must be a table`);
115
+ }
116
+ for (const [serverName, def] of Object.entries(rawServers)) {
117
+ if (typeof def !== "object" || def === null || Array.isArray(def)) {
118
+ throw new Error(`${path}: [mcp.servers.${serverName}] must be a table`);
119
+ }
120
+ const d = def;
121
+ if (typeof d.command !== "string" || d.command.length === 0) {
122
+ throw new Error(`${path}: [mcp.servers.${serverName}].command is required`);
123
+ }
124
+ if (d.args !== undefined && (!Array.isArray(d.args) || !d.args.every((a) => typeof a === "string"))) {
125
+ throw new Error(`${path}: [mcp.servers.${serverName}].args must be string[]`);
126
+ }
127
+ if (d.env !== undefined && (typeof d.env !== "object" || d.env === null || Array.isArray(d.env))) {
128
+ throw new Error(`${path}: [mcp.servers.${serverName}].env must be a table`);
129
+ }
130
+ servers[serverName] = {
131
+ command: d.command,
132
+ ...(Array.isArray(d.args) ? { args: d.args } : {}),
133
+ ...(typeof d.env === "object" && d.env !== null ? { env: d.env } : {}),
134
+ };
135
+ }
136
+ mcp = { servers };
137
+ }
138
+ // [permissions] — install-time boundaries
139
+ let permissions;
140
+ if (parsed.permissions !== undefined) {
141
+ if (typeof parsed.permissions !== "object" || Array.isArray(parsed.permissions)) {
142
+ throw new Error(`${path}: [permissions] must be a table`);
143
+ }
144
+ const p = parsed.permissions;
145
+ for (const key of ["allowed_paths", "denied_paths"]) {
146
+ const v = p[key];
147
+ if (v !== undefined && (!Array.isArray(v) || !v.every((a) => typeof a === "string"))) {
148
+ throw new Error(`${path}: [permissions].${key} must be string[]`);
149
+ }
150
+ }
151
+ if (p.allow_network !== undefined && typeof p.allow_network !== "boolean") {
152
+ throw new Error(`${path}: [permissions].allow_network must be a boolean`);
153
+ }
154
+ permissions = {
155
+ ...(Array.isArray(p.allowed_paths) ? { allowed_paths: p.allowed_paths } : {}),
156
+ ...(Array.isArray(p.denied_paths) ? { denied_paths: p.denied_paths } : {}),
157
+ ...(typeof p.allow_network === "boolean" ? { allow_network: p.allow_network } : {}),
158
+ };
159
+ }
160
+ return { project, dependencies, forge, agents, skills, mcp, permissions, package: parsed.package };
45
161
  }
46
- export function validateProjectToml(p, pathForMsg = "forge.toml") {
162
+ export function validateProjectToml(p) {
47
163
  const errs = [];
48
164
  for (const [name, range] of Object.entries(p.dependencies)) {
49
165
  if (!DEP_NAME_RE.test(name))
@@ -56,5 +172,21 @@ export function validateProjectToml(p, pathForMsg = "forge.toml") {
56
172
  if (p.forge?.harnesses !== undefined && !Array.isArray(p.forge.harnesses)) {
57
173
  errs.push(`[forge].harnesses must be an array`);
58
174
  }
175
+ if (p.skills) {
176
+ for (const [skillName, ref] of Object.entries(p.skills)) {
177
+ if (ref.version && !ref.source && !isValidRange(ref.version)) {
178
+ errs.push(`Invalid semver range for skill "${skillName}": "${ref.version}"`);
179
+ }
180
+ if (ref.source !== undefined && ref.source.length === 0) {
181
+ errs.push(`Empty source for skill "${skillName}"`);
182
+ }
183
+ }
184
+ }
185
+ if (p.mcp?.servers) {
186
+ for (const [serverName, def] of Object.entries(p.mcp.servers)) {
187
+ if (!def.command)
188
+ errs.push(`[mcp.servers.${serverName}].command is required`);
189
+ }
190
+ }
59
191
  return errs;
60
192
  }
@@ -4,14 +4,14 @@ import { toSlug } from "./store.js";
4
4
  import { maxSatisfying } from "./semver.js";
5
5
  import { loadConfig } from "./config.js";
6
6
  function registryRoot() {
7
- // Epoch 1d: config.registry desteğiuzak index URL'si kullanıcı tarafından belirtilebilir
7
+ // Epoch 1d: config.registry supportremote index URL can be user-provided
8
8
  try {
9
9
  const cfg = loadConfig();
10
10
  if (cfg.registry && /^https?:\/\//.test(cfg.registry)) {
11
11
  return cfg.registry;
12
12
  }
13
13
  }
14
- catch { }
14
+ catch { /* config override is optional; fall back to bundled registry */ }
15
15
  const candidates = [
16
16
  join(import.meta.dirname ?? "./", "../../../registry"),
17
17
  join(import.meta.dirname ?? "./", "../../../../registry"),
@@ -25,7 +25,7 @@ function registryRoot() {
25
25
  }
26
26
  export async function loadIndex() {
27
27
  const root = registryRoot();
28
- // Epoch 1d: uzak registry URL'si desteği
28
+ // Epoch 1d: remote registry URL support
29
29
  if (/^https?:\/\//.test(root)) {
30
30
  try {
31
31
  const res = await fetch(root + "/index.json");
@@ -34,7 +34,7 @@ export async function loadIndex() {
34
34
  return await res.json();
35
35
  }
36
36
  catch (e) {
37
- throw new Error(`Remote registry fetch failed: ${e.message}`);
37
+ throw new Error(`Remote registry fetch failed: ${e.message}`, { cause: e });
38
38
  }
39
39
  }
40
40
  const p = join(root, "index.json");
@@ -45,7 +45,7 @@ export async function loadIndex() {
45
45
  export async function loadPackageDetail(pkg) {
46
46
  const root = registryRoot();
47
47
  const slug = toSlug(pkg);
48
- // Epoch 1d: uzak registry URL'si desteği
48
+ // Epoch 1d: remote registry URL support
49
49
  if (/^https?:\/\//.test(root)) {
50
50
  try {
51
51
  const res = await fetch(`${root}/packages/${slug}.json`);
@@ -54,7 +54,7 @@ export async function loadPackageDetail(pkg) {
54
54
  return await res.json();
55
55
  }
56
56
  catch (e) {
57
- throw new Error(`Package not found: ${pkg} (${e.message})`);
57
+ throw new Error(`Package not found: ${pkg} (${e.message})`, { cause: e });
58
58
  }
59
59
  }
60
60
  const p = join(root, "packages", `${slug}.json`);
@@ -75,7 +75,7 @@ export async function resolveVersion(pkg, requested) {
75
75
  if (detail.versions[requested]) {
76
76
  return { detail, version: requested, versionMeta: detail.versions[requested] };
77
77
  }
78
- // semver range (Faz 10: semver.ts tek kaynak) — pick highest satisfying
78
+ // semver range (Phase 10: semver.ts is the single source) — pick highest satisfying
79
79
  const picked = maxSatisfying(Object.keys(detail.versions), requested);
80
80
  if (!picked)
81
81
  throw new Error(`No version satisfying ${requested} for ${pkg}. Available: ${Object.keys(detail.versions).join(", ")}`);
@@ -91,8 +91,8 @@ export function parsePackageArg(arg) {
91
91
  }
92
92
  return { name: arg };
93
93
  }
94
- // --- semver helpers Faz 10'da semver.ts'e taşındı (tek kaynak) ---
95
- // Bu dosya geriye dönük uyumluluk için re-export eder.
94
+ // --- semver helpers moved to semver.ts in Phase 10 (single source) ---
95
+ // This file re-exports for backwards compatibility.
96
96
  export { parseSemver, compareSemver, satisfiesRange, maxSatisfying, isValidRange } from "./semver.js";
97
97
  export async function searchPackages(query, opts = {}) {
98
98
  const index = await loadIndex();
@@ -0,0 +1,159 @@
1
+ // cli/src/core/scan.ts — Static security scanner (Phase 3).
2
+ //
3
+ // AI agents execute third-party skills/prompts, so every package is scanned
4
+ // BEFORE install and BY audit afterwards. Three rule families:
5
+ //
6
+ // shell-danger — destructive/remote-code shell in scripts
7
+ // (rm -rf /, curl|bash, fork bombs, cred theft)
8
+ // prompt-inject — hidden instruction overrides + exfiltration in prompts
9
+ // (ignore-previous, send secrets to http, embedded keys)
10
+ // perm-violation — package content touching project [permissions].denied_paths
11
+ //
12
+ // Severity: high blocks installs (fail-closed, exit 1); medium/low warn.
13
+ // Scanners are regex-based, dependency-free, and deterministic.
14
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
15
+ import { join, relative, extname, basename } from "path";
16
+ const PROMPT_EXTS = [".md", ".mdc", ".txt", ".json", ".yaml", ".yml", ".toml"];
17
+ const RULES = [
18
+ // --- shell-danger (high) ---
19
+ { id: "rm-rf-root", family: "shell-danger", severity: "high", pattern: /\brm\s+(-[a-z]*r[a-z]*f|--recursive\s+--force)\s+\/( |$)/, message: "recursive delete rooted at /", exts: [] },
20
+ { id: "curl-pipe-shell", family: "shell-danger", severity: "high", pattern: /\bcurl\b[^\n|]*\|\s*(bash|sh)(\s|$)/, message: "curl piped into a shell (remote code execution)", exts: [] },
21
+ { id: "wget-pipe-shell", family: "shell-danger", severity: "high", pattern: /\bwget\b[^\n|]*\|\s*(bash|sh)(\s|$)/, message: "wget piped into a shell (remote code execution)", exts: [] },
22
+ { id: "fork-bomb", family: "shell-danger", severity: "high", pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;?/, message: "shell fork bomb", exts: [] },
23
+ { id: "disk-wipe", family: "shell-danger", severity: "high", pattern: /\b(mkfs|dd\s+[^\n]*of=\/dev\/)/, message: "disk wipe / raw device write", exts: [] },
24
+ { id: "chmod-777-root", family: "shell-danger", severity: "high", pattern: /\bchmod\s+(-R\s+)?777\s+\//, message: "chmod 777 on a system path", exts: [] },
25
+ { id: "reverse-shell", family: "shell-danger", severity: "high", pattern: /\bnc(\.exe)?\s+[^\n]*-e\s+\S|bash\s+-i\s+>&\s*\/dev\/tcp\//, message: "reverse shell", exts: [] },
26
+ { id: "ssh-key-theft", family: "shell-danger", severity: "high", pattern: /\b(cat|type|Get-Content)\s+[^\n]*(id_rsa|id_ed25519|\.ssh\/)/, message: "reads private SSH keys", exts: [] },
27
+ { id: "powershell-encoded", family: "shell-danger", severity: "high", pattern: /powershell[^\n]*-(e(nc(odedcommand)?)?)\b/i, message: "encoded PowerShell payload", exts: [] },
28
+ // --- shell-danger (medium) ---
29
+ { id: "sudo-curl", family: "shell-danger", severity: "medium", pattern: /\bsudo\s+(curl|wget)\b/, message: "privileged download", exts: [] },
30
+ { id: "env-exfil-curl", family: "shell-danger", severity: "medium", pattern: /\bcurl\b[^\n]*\$(?:\{(?:AWS_|GITHUB_|OPENAI_|ANTHROPIC_|API_KEY|TOKEN|SECRET))/, message: "curl sends a secret-looking env var", exts: [] },
31
+ // --- prompt-inject (high) ---
32
+ { id: "ignore-instructions", family: "prompt-inject", severity: "high", pattern: /\b(ignore|disregard)\s+(all\s+)?(previous|prior|above)\s+instructions\b/i, message: "instruction override (prompt injection)", exts: PROMPT_EXTS },
33
+ { id: "system-role-hijack", family: "prompt-inject", severity: "high", pattern: /you are now (a|an|the)\b.{0,80}?(assistant|agent|system|root|admin)/i, message: "role hijack (prompt injection)", exts: PROMPT_EXTS },
34
+ { id: "send-secrets-http", family: "prompt-inject", severity: "high", pattern: /\b(send|post|upload|exfiltrat\w*)\b[^\n]{0,120}?(api[\s_-]?key|secret|token|password|private[\s_-]?key)[^\n]{0,80}?\bhttps?:\/\//i, message: "instructs secret exfiltration over http", exts: PROMPT_EXTS },
35
+ // --- prompt-inject (medium) ---
36
+ { id: "embedded-aws-key", family: "prompt-inject", severity: "medium", pattern: /\bAKIA[0-9A-Z]{16}\b/, message: "embedded AWS access key", exts: [] },
37
+ { id: "embedded-github-token", family: "prompt-inject", severity: "medium", pattern: /\bghp_[a-zA-Z0-9]{20,}\b/, message: "embedded GitHub token", exts: [] },
38
+ { id: "embedded-private-key", family: "prompt-inject", severity: "medium", pattern: /-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/, message: "embedded private key", exts: [] },
39
+ { id: "read-env-file", family: "prompt-inject", severity: "medium", pattern: /\b(cat|type|Get-Content|read)\s+[^\n]*\.env\b/i, message: "reads .env secrets file", exts: PROMPT_EXTS },
40
+ ];
41
+ const MAX_FILE_BYTES = 512 * 1024;
42
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".hg", ".svn", "__pycache__", "dist", "build", ".cache"]);
43
+ function listTextFiles(dir, out = []) {
44
+ let entries;
45
+ try {
46
+ entries = readdirSync(dir, { withFileTypes: true });
47
+ }
48
+ catch {
49
+ return out;
50
+ }
51
+ for (const e of entries) {
52
+ if (e.name.startsWith(".forge-"))
53
+ continue;
54
+ const full = join(dir, e.name);
55
+ if (e.isDirectory()) {
56
+ if (!SKIP_DIRS.has(e.name))
57
+ listTextFiles(full, out);
58
+ }
59
+ else if (e.isFile()) {
60
+ try {
61
+ if (statSync(full).size <= MAX_FILE_BYTES)
62
+ out.push(full);
63
+ }
64
+ catch {
65
+ /* unreadable — skip */
66
+ }
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ function snippet(line) {
72
+ const t = line.trim();
73
+ return t.length > 120 ? `${t.slice(0, 120)}…` : t;
74
+ }
75
+ /** Scan one package content dir. Deterministic: files sorted, rules ordered. */
76
+ export function scanPackageDir(dir, opts = {}) {
77
+ const findings = [];
78
+ if (!existsSync(dir))
79
+ return findings;
80
+ const files = listTextFiles(dir).sort();
81
+ for (const file of files) {
82
+ const rel = relative(dir, file).replace(/\\/g, "/");
83
+ const ext = extname(file).toLowerCase();
84
+ let raw;
85
+ try {
86
+ raw = readFileSync(file, "utf-8");
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ if (raw.indexOf("\0") !== -1)
92
+ continue; // binary
93
+ const lines = raw.split("\n");
94
+ for (const rule of RULES) {
95
+ if (rule.exts.length > 0 && !rule.exts.includes(ext) && basename(file) !== "Dockerfile")
96
+ continue;
97
+ for (let i = 0; i < lines.length; i++) {
98
+ // reset stateful regexes
99
+ rule.pattern.lastIndex = 0;
100
+ if (rule.pattern.test(lines[i])) {
101
+ findings.push({
102
+ rule: rule.id,
103
+ family: rule.family,
104
+ severity: rule.severity,
105
+ file: rel,
106
+ line: i + 1,
107
+ match: snippet(lines[i]),
108
+ message: rule.message,
109
+ });
110
+ break; // one hit per rule per file keeps output stable
111
+ }
112
+ }
113
+ }
114
+ }
115
+ findings.push(...checkPermissions(dir, files, opts.permissions));
116
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule));
117
+ return findings;
118
+ }
119
+ function globToRegExp(glob) {
120
+ // Minimal glob: * matches any run except /, **/ matches any depth.
121
+ const token = "FORGEGLOBSTAR";
122
+ const esc = glob
123
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
124
+ .replace(/\*\*\//g, token)
125
+ .replace(/\*/g, "[^/]*")
126
+ .split(token)
127
+ .join(".*");
128
+ return new RegExp(`(^|/)${esc}$`, "i");
129
+ }
130
+ /** Flag package content touching project [permissions].denied_paths. */
131
+ export function checkPermissions(dir, files, permissions) {
132
+ const denied = permissions?.denied_paths ?? [];
133
+ if (denied.length === 0)
134
+ return [];
135
+ const patterns = denied.map(globToRegExp);
136
+ const out = [];
137
+ for (const file of files) {
138
+ const rel = relative(dir, file).replace(/\\/g, "/");
139
+ const base = basename(file);
140
+ if (patterns.some((p) => p.test(rel) || p.test(base))) {
141
+ out.push({
142
+ rule: "denied-path-content",
143
+ family: "perm-violation",
144
+ severity: "high",
145
+ file: rel,
146
+ line: 0,
147
+ match: rel,
148
+ message: `package ships content matching project denied_paths (${rel})`,
149
+ });
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+ export function countBySeverity(findings) {
155
+ const out = { high: 0, medium: 0, low: 0 };
156
+ for (const f of findings)
157
+ out[f.severity]++;
158
+ return out;
159
+ }