opencode-v2-guard 1.0.2 → 1.1.0

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 CHANGED
@@ -18,7 +18,16 @@ Audit: JSONL at `~/.local/share/opencode/guard/guard-audit.jsonl` — categories
18
18
 
19
19
  ## Dependency installation guard
20
20
 
21
- `npm/pnpm/yarn add`, `pip install`, `cargo add` are parsed before execution (phase 2): a built-in blocklist of known-malicious packages (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) vetoes the install, and names at Levenshtein distance 1 from a curated top-120 npm list are flagged as typosquats (npm only — other ecosystems reuse the same names legitimately). Global installs (`-g`) are out of scope. Post-install auditing (npm audit / OSV) is planned for phase 2.5.
21
+ `npm/pnpm/yarn add`, `pip install`, `cargo add` are parsed before execution (phase 2): a built-in blocklist of known-malicious packages (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) vetoes the install, and names at Levenshtein distance 1 from a curated top-120 npm list are flagged as typosquats (npm only — other ecosystems reuse the same names legitimately). Global installs (`-g`) are out of scope.
22
+
23
+ Phase 2.5 additions:
24
+
25
+ - `npx pkg`, `pnpm dlx`, `yarn dlx` are checked the same way (execution without install)
26
+ - `@types/*` scoped squatting (`@types/lodahs` → veto), affix squatting (`lodashjs`, `node-lodash`, `react-cli`)
27
+ - `blockedPackages`/`allowedPackages` accept `name@version` (blocks only that version; plain `name` covers all versions)
28
+ - post-audit: ~60s after an allowed npm install, `npm audit --json` runs in the project directory; findings are warn-only audit entries (`postAudit`, default on)
29
+
30
+ Known gaps: `npm exec` is not parsed; `npx -c "<string>"` contents are not scanned; versioned entries match literally (`rc@1.2.0` does not catch `rc@^1.2.0` or `rc@latest` unless written exactly); post-audit is npm-only (no yarn/pnpm audit) and its 120s timeout is enforced via `taskkill` on Windows (best-effort elsewhere); a forced-kill partial report is dropped; `corepack <pm> ...` indirection and command substitution (`$(npm i x)`) are not expanded.
22
31
 
23
32
  ## Detection (29 patterns)
24
33
 
@@ -67,6 +76,7 @@ OpenCode installs the npm package automatically on startup. Then `opencode servi
67
76
  | `scanOutbound` | `true` | scrub outbound context |
68
77
  | `scanSkillsOnLoad` | `true` | scan installed skills at startup (warn-only audit) |
69
78
  | `depsCheck` | `true` | intercept package installs (blocklist + typosquat) |
79
+ | `postAudit` | `true` | npm audit ~60s after an allowed npm install (warn-only) |
70
80
  | `blockedPackages` | `[]` | extra package names blocked from install (union) |
71
81
  | `allowedPackages` | `[]` | names exempted from the blocklist (escape hatch) |
72
82
  | `blockPaths` | built-in list | **added** to defaults (union) |
@@ -140,7 +150,16 @@ opencode service restart
140
150
 
141
151
  ## Защита установки зависимостей
142
152
 
143
- `npm/pnpm/yarn add`, `pip install`, `cargo add` парсятся до исполнения (фаза 2): встроенный blocklist известных вредоносных пакетов (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) блокирует установку, а имена на расстоянии Левенштейна 1 от топ-120 npm ловятся как typosquat (только npm — в других экосистемах те же имена легитимны). Глобальные установки (`-g`) вне зоны охвата. Пост-аудит (npm audit / OSV) — фаза 2.5.
153
+ `npm/pnpm/yarn add`, `pip install`, `cargo add` парсятся до исполнения (фаза 2): встроенный blocklist известных вредоносных пакетов (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) блокирует установку, а имена на расстоянии Левенштейна 1 от топ-120 npm ловятся как typosquat (только npm — в других экосистемах те же имена легитимны). Глобальные установки (`-g`) вне зоны охвата.
154
+
155
+ Добавления фазы 2.5:
156
+
157
+ - `npx pkg`, `pnpm dlx`, `yarn dlx` проверяются так же (исполнение без install)
158
+ - scoped-сквоттинг `@types/*` (`@types/lodahs` → вето), affix-сквоттинг (`lodashjs`, `node-lodash`, `react-cli`)
159
+ - `blockedPackages`/`allowedPackages` принимают `name@version` (блокирует только эту версию; голое `name` — все версии)
160
+ - пост-аудит: через ~60 секунд после пропущенной npm-установки в каталоге проекта запускается `npm audit --json`; находки пишутся в аудит как warn (`postAudit`, по умолчанию включён)
161
+
162
+ Известные пробелы: `npm exec` не парсится; содержимое `npx -c "<строка>"` не сканируется; версионные записи сопоставляются литерально (`rc@1.2.0` не поймает `rc@^1.2.0` или `rc@latest`, если записано иначе); пост-аудит только для npm (yarn/pnpm — нет), таймаут 120s на Windows исполняется через `taskkill` (best-effort), оборванный принудительным kill отчёт отбрасывается; косвенность `corepack <pm> ...` и подстановка команд (`$(npm i x)`) не раскрываются.
144
163
 
145
164
  ## Детекция (29 паттернов)
146
165
 
@@ -190,6 +209,7 @@ OpenCode сам установит npm-пакет при старте. Зате
190
209
  | `scanOutbound` | `true` | скраб исходящего контекста |
191
210
  | `scanSkillsOnLoad` | `true` | стартовый скан установленных скиллов (только warn-аудит) |
192
211
  | `depsCheck` | `true` | перехват установки пакетов (blocklist + typosquat) |
212
+ | `postAudit` | `true` | npm audit через ~60с после пропущенной npm-установки (только warn) |
193
213
  | `blockedPackages` | `[]` | дополнительные запрещённые имена пакетов (union) |
194
214
  | `allowedPackages` | `[]` | имена, снятые с блоклиста (обход ложных срабатываний) |
195
215
  | `blockPaths` | дефолтный список | **добавляется** к дефолтам (union) |
@@ -0,0 +1,106 @@
1
+ # Спека: deps-guard фаза 2.5 — доводка перехвата зависимостей
2
+
3
+ Статус: принята к реализации. Процесс: спека → тесты (красный) → реализация (зелёный) → ревью субагентами → живая проверка → v1.1.0.
4
+
5
+ Закрывает §9 deps-spec.md. Исследование API (шаг 0): `shell.create.before` несёт `cwd` —
6
+ пост-аудит реализуем на нём; `tool.execute.before` cwd не несёт (sessionID/agent/messageID/input).
7
+
8
+ ## 1. npx / dlx-перехват (загрузка без install)
9
+
10
+ `npx pkg[@ver]`, `pnpm dlx pkg`, `yarn dlx pkg` скачивают и исполняют пакет без install — тот же
11
+ риск, что у install. Детерминированный парсинг:
12
+
13
+ - менеджер: `npx` (+Windows `npx.cmd`/`.ps1`/`.exe`), подкоманды `dlx` у `pnpm`/`yarn`.
14
+ - флаги пропускаются; `-p`/`--package` (npx) — операнд(ы) ЭТО пакеты (проверяются).
15
+ - первый позиционный токен после флагов — пакет для исполнения (имя до `@`, версия после).
16
+ Токены после `--` — аргументы исполняемой команды, НЕ пакеты (стоп).
17
+ - `-c`/`--call` со строкой-командой — не парсим (содержимое строки не трогаем).
18
+ - git/url-спеки (`npx github:user/repo`) → null.
19
+ - применяются те же проверки: blocklist (case-insens) + squat npm-only + affix (п.3).
20
+ - `npm exec` НЕ покрывается (семантика позиционных аргументов неоднозначна) — documented gap.
21
+
22
+ ## 2. Scoped-сквоттинг (@types)
23
+
24
+ `@types/<base>`: если `<base>` ∈ TRUSTED_TOP → ок; иначе levenshtein(base, trusted) == 1 → typosquat.
25
+ Примеры: `@types/lodahs` → lodash (вето); `@types/lodash` → ок; `@scope/lodahs` → ок (фаза 3:
26
+ scoped-сквоттинг произвольных скоупов требует списка официальных скоупов).
27
+
28
+ ## 3. Affix-сквоттинг (префикс/суффикс)
29
+
30
+ Имя = trusted + словарный аффикс (склейка или через дефис):
31
+ - суффиксы: `js`, `ts`, `node`, `cli`, `dev` (+ дефисные формы `-js`, `-ts`, `-node`, `-cli`, `-dev`)
32
+ - префиксы: `node-`, `nodejs-`
33
+ Ловит `lodashjs`, `node-lodash`, `react-cli`, `lodash-js`. Не ловит `lodash-es` (сам в TRUSTED_TOP).
34
+ Allowlist легитимных исторических имён: `angularjs`, `reactjs`, `vuejs`, `expressjs`, `nodejs`,
35
+ `vue-cli`, `angular-cli` (официальные legacy CLI). Только npm-экосистема.
36
+ Причина в аудите: `typosquat` (target = trusted).
37
+
38
+ ## 4. Версионный блоклист
39
+
40
+ Записи `blockedPackages` вида `name@version` блокируют ТОЛЬКО эту версию; голые `name` — как раньше,
41
+ всё имя. `rc@1.2.9` в блоклисте: `npm i rc@1.2.9` → вето, `npm i rc` → ок, `npm i rc@2.0.0` → ок.
42
+ Scoped: `@scope/pkg@1.0.0` парсится корректно (последний `@`).
43
+ `allowedPackages` поддерживает обе формы; голое имя exempt-ит все версии.
44
+ Версионный allow НЕ снимает имённый блок (только версионную запись того же имени).
45
+ Встроенный BLOCKED_PACKAGES остаётся имённым (инцидентные имена).
46
+
47
+ ## 5. Пост-аудит (npm audit, warn-only)
48
+
49
+ В `shell.create.before`: если команда — пропущенный npm-install (npm/yarn/pnpm? — только npm:
50
+ npm audit работает по npm-lockfile; yarn/pnpm — gap) и `postAudit` (default true) —
51
+ через 60 сек (эвристика: install успеет завершиться) отсоединённо запускаем
52
+ `npm audit --json` в `event.cwd` (timeout 120s, stdio pipe, ошибки глотаем).
53
+ Результат: `parseAuditReport(json)` агрегирует по severity (v2: `auditReport.vulnerabilities`,
54
+ v1: `advisories`; неизвестные severity → `unknown` — блокирует `__proto__`-строки). Находки → аудит
55
+ `{"kind":"deps","categories":["audit-vulnerability"],"count":N,"action":"warn",severities:{...}}`
56
+ + console.warn. Блокировать постфактум нельзя — это детекция уже установленного.
57
+ Планируется и для пропущенных команд в warn-режиме (install выполнился — самый опасный класс).
58
+ Дедуп по cwd (5 мин). Таймаут: на win32 свой `taskkill /PID /T /F` (встроенный spawn-timeout
59
+ убивает только cmd.exe-обёртку), иначе SIGTERM; оборванный отчёт отбрасывается.
60
+
61
+ ## 6. Конфиг
62
+
63
+ Новый ключ: `postAudit: boolean` (default `true`). Остальное — через существующие
64
+ blockedPackages/allowedPackages (новые формы записи, п.4).
65
+
66
+ ## 7. Файлы
67
+
68
+ | Файл | Изменение |
69
+ |---|---|
70
+ | `src/deps.ts` | npx/dlx парсинг, @types-scoped, affix, версионный блоклист, parseAuditReport |
71
+ | `src/guard.ts` | post-audit планировщик в shell.create.before |
72
+ | `src/config.ts` | `postAudit` |
73
+ | `src/audit.ts` | AuditEvent: опциональный `severities?: Record<string, number>` |
74
+ | `test/deps.test.ts` | +~25 тестов |
75
+ | `README.md` | секция deps: npx/dlx, @types, affix, версионный блоклист, пост-аудит (EN+RU) |
76
+
77
+ ## 8. План тестов (TDD)
78
+
79
+ 1. npx: `npx lodahs`→squat; `npx -y lodahs@1.2.3`→squat; `npx -p event-stream`→blocklist;
80
+ `npx -p lodash node x.js`→первый позиционный `node`… подождать: `-p` пакеты проверяются,
81
+ позиционные тоже; `npx lodash`→null; `npx node script.js`→null; `npx -- lodahs`→null
82
+ (после `--` аргументы); `npx.cmd lodahs`→squat (Windows); `npx github:x/y`→null.
83
+ 2. dlx: `pnpm dlx lodahs`→squat; `yarn dlx event-stream`→blocklist; `pnpm dlx lodash`→null.
84
+ 3. @types: `@types/lodahs`→squat(lodash); `@types/lodash`→null; `@types/reactt`→squat(react);
85
+ `@scope/lodahs`→null.
86
+ 4. affix: `lodashjs`/`node-lodash`/`react-cli`→squat; `angularjs`/`expressjs`/`vuejs`/
87
+ `nodejs`/`reactjs`→null (allowlist); `lodash-es`→null (trusted); `nodemailer`→null.
88
+ 5. версионный блоклист: `rc@1.2.9` блокирует только эту версию; `@scope/pkg@1.0.0` scoped-парс;
89
+ allowed `rc` снимает версионный тоже; allowed `rc@1.2.9` снимает только её.
90
+ 6. parseAuditReport: v2-фикстура (2 critical, 1 high) → severities агрегированы; v1-фикстура;
91
+ мусор → null; пустой отчёт → нули.
92
+ 7. регрессии: все существующие 111 тестов зелёные.
93
+
94
+ ## 9. Критерии готовности
95
+
96
+ - 111 + новые тесты зелёные, tsc чисто.
97
+ - Ревью субагентом: critical/major исправлены.
98
+ - Живая проверка: `npx lodahs` → вето; `npm install lodash` → ок + через ~60с в аудите
99
+ kind:"deps" с audit-vulnerability (если в проекте есть уязвимости) либо тишина.
100
+ - Публикация v1.1.0.
101
+
102
+ ## 10. Known gaps (после 2.5)
103
+
104
+ - `npm exec`, произвольные scoped-скоупы, yarn/pnpm audit, affix-словарь фиксирован.
105
+ - пост-аудит: эвристическая задержка 60s, npm-only, детекция без блокировки.
106
+ - OSV-API как альтернатива npm audit — фаза 3 (нужен выбор источника без ключа).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-v2-guard",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Security plugin for OpenCode V2 — deterministic secret/PII redaction, sensitive-path blocking, prompt-injection detection in skills, package-install interception (blocklist + typosquat)",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/audit.ts CHANGED
@@ -9,6 +9,8 @@ export interface AuditEvent {
9
9
  categories: string[]
10
10
  count: number
11
11
  action: "redacted" | "blocked" | "warn"
12
+ /** optional severity breakdown (npm post-audit) */
13
+ severities?: Record<string, number>
12
14
  }
13
15
 
14
16
  /**
package/src/config.ts CHANGED
@@ -13,6 +13,8 @@ export interface GuardConfig {
13
13
  exemptSkills: string[]
14
14
  /** intercept package installation commands (blocklist + typosquat) */
15
15
  depsCheck: boolean
16
+ /** run `npm audit --json` ~60s after an allowed npm install (warn-only detection) */
17
+ postAudit: boolean
16
18
  /** extra package names blocked from installation (union with built-in list) */
17
19
  blockedPackages: string[]
18
20
  /** package names exempted from the blocklist (escape hatch for legit installs) */
@@ -67,6 +69,7 @@ export function resolveConfig(options: unknown): GuardConfig {
67
69
  scanSkillsOnLoad: o.scanSkillsOnLoad !== false,
68
70
  exemptSkills: strArray(o.exemptSkills) ? [...o.exemptSkills] : [],
69
71
  depsCheck: o.depsCheck !== false,
72
+ postAudit: o.postAudit !== false,
70
73
  blockedPackages: strArray(o.blockedPackages) ? [...o.blockedPackages] : [],
71
74
  allowedPackages: strArray(o.allowedPackages) ? [...o.allowedPackages] : [],
72
75
  blockPaths: strArray(o.blockPaths) ? [...new Set([...DEFAULT_BLOCK_PATHS, ...o.blockPaths])] : [...DEFAULT_BLOCK_PATHS],
package/src/deps.ts CHANGED
@@ -12,19 +12,20 @@ export interface InstallPackage {
12
12
  }
13
13
 
14
14
  export interface InstallTarget {
15
- manager: "npm" | "yarn" | "pnpm" | "pip" | "cargo"
15
+ manager: "npm" | "yarn" | "pnpm" | "pip" | "cargo" | "npx" | "bun"
16
16
  packages: InstallPackage[]
17
17
  }
18
18
 
19
- const MANAGERS = new Set(["npm", "yarn", "pnpm", "pip", "pip3", "cargo"])
19
+ const MANAGERS = new Set(["npm", "yarn", "pnpm", "pip", "pip3", "cargo", "npx", "pnpx", "bun", "bunx"])
20
20
 
21
21
  const SUBCOMMANDS: Record<string, readonly string[]> = {
22
22
  npm: ["install", "i"],
23
- yarn: ["add"],
24
- pnpm: ["add", "install"],
23
+ yarn: ["add", "dlx"],
24
+ pnpm: ["add", "install", "dlx"],
25
25
  pip: ["install"],
26
26
  pip3: ["install"],
27
27
  cargo: ["add"],
28
+ bun: ["add", "install", "x"],
28
29
  }
29
30
 
30
31
  /** flags that consume the NEXT token as their operand (never a package name) */
@@ -46,6 +47,71 @@ export const BLOCKED_PACKAGES: ReadonlySet<string> = new Set([
46
47
  "rc", // 2021: hijack, same campaign as coa
47
48
  ])
48
49
 
50
+ /** affix squatting: trusted + short dictionary affix, with or without a dash (npm-only check) */
51
+ const AFFIX_SUFFIXES = ["js", "ts", "node", "cli", "dev", "-js", "-ts", "-node", "-cli", "-dev"] as const
52
+ const AFFIX_PREFIXES = ["node-", "nodejs-"] as const
53
+ /** historical legit names that would otherwise match the affix rule */
54
+ const AFFIX_ALLOWLIST: ReadonlySet<string> = new Set(["angularjs", "reactjs", "vuejs", "expressjs", "nodejs", "vue-cli", "angular-cli"])
55
+
56
+ /** git/url install specs are not registry names — never flag them */
57
+ function isGitOrUrlSpec(token: string): boolean {
58
+ return (
59
+ /:\/\//.test(token) ||
60
+ /^(?:git@|github:|gitlab:|bitbucket:|gist:)/.test(token) ||
61
+ /\.(?:tgz|tar\.gz|zip)$/i.test(token)
62
+ )
63
+ }
64
+
65
+ /** split name@version on the LAST '@' (respects scoped names: @scope/pkg@1.0.0) */
66
+ function splitNameVersion(entry: string): { name: string; version?: string } {
67
+ const at = entry.lastIndexOf("@")
68
+ if (at > 0) return { name: entry.slice(0, at), version: entry.slice(at + 1) || undefined }
69
+ return { name: entry }
70
+ }
71
+
72
+ /**
73
+ * npx / pnpm dlx / yarn dlx: the executed package is a positional token, not an install operand.
74
+ * Flags skipped; -p/--package operands ARE packages; everything after `--` is the command's args.
75
+ */
76
+ function parseExecTokens(tokens: string[], start: number): InstallTarget | null {
77
+ const packages: InstallPackage[] = []
78
+ let positionalTaken = false
79
+ let ignorePositional = false
80
+ for (let j = start; j < tokens.length; j++) {
81
+ const t = tokens[j]
82
+ if (t === "--") break // rest belongs to the executed command
83
+ if (t.startsWith("-")) {
84
+ if (t === "-p" || t === "--package") {
85
+ const next = tokens[++j]
86
+ if (next && !isGitOrUrlSpec(next)) packages.push(splitNameVersion(next))
87
+ continue
88
+ }
89
+ if (t.startsWith("-p=")) {
90
+ const v = t.slice(3)
91
+ if (v && !isGitOrUrlSpec(v)) packages.push(splitNameVersion(v))
92
+ continue
93
+ }
94
+ if (t.startsWith("--package=")) {
95
+ const v = t.slice("--package=".length)
96
+ if (v && !isGitOrUrlSpec(v)) packages.push(splitNameVersion(v))
97
+ continue
98
+ }
99
+ if (t === "-c" || t === "--call") {
100
+ j++ // consume the shell string
101
+ ignorePositional = true
102
+ continue
103
+ }
104
+ continue
105
+ }
106
+ if (!positionalTaken && !ignorePositional && !isGitOrUrlSpec(t)) {
107
+ packages.push(splitNameVersion(t))
108
+ positionalTaken = true
109
+ }
110
+ }
111
+ if (packages.length === 0) return null
112
+ return { manager: "npx", packages }
113
+ }
114
+
49
115
  /** curated popular npm names for typosquat distance-1 checks (119 unique) */
50
116
  export const TRUSTED_TOP: ReadonlySet<string> = new Set([
51
117
  "lodash", "react", "react-dom", "vue", "angular", "express", "axios", "moment",
@@ -98,15 +164,34 @@ function parseSegment(segment: string): InstallTarget | null {
98
164
  // Windows runners: npm.cmd, pnpm.cmd, pip.exe, yarn.ps1, ... — strip the script/extension suffix
99
165
  const raw = tokens[i]
100
166
  if (!raw) return null
101
- const manager = raw.replace(/\.(?:cmd|ps1|exe|bat)$/i, "")
167
+ const manager = raw.replace(/\.(?:cmd|ps1|exe|bat)$/i, "").toLowerCase()
102
168
  if (!MANAGERS.has(manager)) return null
103
- const sub = tokens[i + 1]
169
+ // execution-without-install forms: npx/pnpx/bunx pkg / pnpm|yarn dlx / bun x
170
+ if (manager === "npx" || manager === "pnpx" || manager === "bunx") return parseExecTokens(tokens, i + 1)
171
+ if ((manager === "pnpm" || manager === "yarn") && tokens[i + 1]?.toLowerCase() === "dlx") {
172
+ return parseExecTokens(tokens, i + 2)
173
+ }
174
+ if (manager === "bun" && tokens[i + 1]?.toLowerCase() === "x") {
175
+ return parseExecTokens(tokens, i + 2)
176
+ }
177
+ // global flags may precede the subcommand: npm --prefix . install pkg / yarn --cwd p add pkg
178
+ const takesArgEarly = FLAG_TAKES_ARG[manager]
179
+ let k = i + 1
180
+ let globalFlag = false
181
+ while (k < tokens.length && tokens[k].startsWith("-")) {
182
+ const flag = tokens[k]
183
+ if (flag === "-g" || flag === "--global") globalFlag = true
184
+ k++
185
+ if (takesArgEarly?.has(flag) && k < tokens.length) k++ // consume the flag's operand
186
+ }
187
+ if (globalFlag) return null
188
+ const sub = tokens[k]?.toLowerCase()
104
189
  if (!SUBCOMMANDS[manager]?.includes(sub)) return null
105
- if (manager === "yarn" && tokens[i + 2] === "global") return null
190
+ if (manager === "yarn" && tokens[k + 1]?.toLowerCase() === "global") return null
106
191
 
107
192
  const takesArg = FLAG_TAKES_ARG[manager]
108
193
  const packages: InstallPackage[] = []
109
- for (let j = i + 2; j < tokens.length; j++) {
194
+ for (let j = k + 1; j < tokens.length; j++) {
110
195
  const t = tokens[j]
111
196
  if (t.startsWith("-")) {
112
197
  if (t === "-g" || t === "--global") return null
@@ -118,13 +203,18 @@ function parseSegment(segment: string): InstallTarget | null {
118
203
  if (manager === "pip" || manager === "pip3") {
119
204
  const m = /^([^=<>~!]+?)([=<>~!].*)?$/.exec(t)
120
205
  if (!m || !m[1]) continue
121
- name = m[1]
206
+ name = m[1].replace(/\[[^\]]*\]$/, "") // extras: event-stream[socks] -> event-stream
122
207
  version = m[2] ? m[2].replace(/^[=<>~!]+/, "") : undefined
123
208
  } else {
124
209
  const at = t.lastIndexOf("@")
125
210
  if (at > 0) {
126
211
  name = t.slice(0, at)
127
212
  version = t.slice(at + 1)
213
+ // npm alias: alias@npm:event-stream actually installs event-stream
214
+ if (version.startsWith("npm:") && version.length > 4) {
215
+ name = version.slice(4)
216
+ version = undefined
217
+ }
128
218
  }
129
219
  }
130
220
  if (!name) continue
@@ -140,7 +230,7 @@ function parseSegment(segment: string): InstallTarget | null {
140
230
  */
141
231
  export function parseSegments(command: string): InstallTarget[] {
142
232
  const out: InstallTarget[] = []
143
- for (const segment of command.split(/&&|\|\||[;|]|\n/)) {
233
+ for (const segment of command.split(/&&|\|\||[;|&]|\n/)) {
144
234
  const hit = parseSegment(segment)
145
235
  if (hit) out.push(hit)
146
236
  }
@@ -181,7 +271,18 @@ export function levenshtein(a: string, b: string): number {
181
271
  /** returns the trusted name this package is squatting, or null (case-insensitive) */
182
272
  export function isTyposquat(name: string): string | null {
183
273
  const n = name.toLowerCase()
184
- if (n.startsWith("@") || n.length < 4) return null
274
+ if (n.length < 4) return null
275
+ // scoped: only @types/* is checked (arbitrary scopes need an official-scope list — phase 3)
276
+ if (n.startsWith("@")) {
277
+ if (!n.startsWith("@types/")) return null
278
+ const base = n.slice("@types/".length)
279
+ if (TRUSTED_TOP.has(base)) return null
280
+ for (const trusted of TRUSTED_TOP) {
281
+ if (Math.abs(trusted.length - base.length) > 1) continue
282
+ if (levenshtein(base, trusted) === 1) return trusted
283
+ }
284
+ return null
285
+ }
185
286
  if (TRUSTED_TOP.has(n)) return null
186
287
  for (const trusted of TRUSTED_TOP) {
187
288
  if (Math.abs(trusted.length - n.length) > 1) continue
@@ -190,6 +291,21 @@ export function isTyposquat(name: string): string | null {
190
291
  return null
191
292
  }
192
293
 
294
+ /** affix squatting: lodashjs / node-lodash / react-cli — trusted + dictionary affix */
295
+ export function isAffixSquat(name: string): string | null {
296
+ const n = name.toLowerCase()
297
+ if (TRUSTED_TOP.has(n) || AFFIX_ALLOWLIST.has(n)) return null
298
+ for (const trusted of TRUSTED_TOP) {
299
+ for (const suffix of AFFIX_SUFFIXES) {
300
+ if (n === trusted + suffix) return trusted
301
+ }
302
+ for (const prefix of AFFIX_PREFIXES) {
303
+ if (n === prefix + trusted) return trusted
304
+ }
305
+ }
306
+ return null
307
+ }
308
+
193
309
  export interface DepsVerdict {
194
310
  name: string
195
311
  reason: "blocklist" | "typosquat"
@@ -198,24 +314,56 @@ export interface DepsVerdict {
198
314
 
199
315
  /**
200
316
  * Check a raw shell command; returns the first violation or null.
201
- * extraBlocked: user names added to the blocklist; allowed: user names exempted from it.
317
+ * extraBlocked/allowed entries: plain "name" (all versions) or "name@version" (that version only).
202
318
  */
203
319
  export function checkInstall(command: string, extraBlocked: readonly string[] = [], allowed: readonly string[] = []): DepsVerdict | null {
204
320
  const targets = parseSegments(command)
205
321
  if (targets.length === 0) return null
206
- const ok = new Set(allowed.map((p) => p.toLowerCase()))
207
- const blocked = new Set([...BLOCKED_PACKAGES, ...extraBlocked].map((p) => p.toLowerCase()))
322
+
323
+ const allowedWide = new Set<string>()
324
+ const allowedVer = new Set<string>() // "name@version", lowercased
325
+ for (const e of allowed) {
326
+ const { name, version } = splitNameVersion(e)
327
+ const l = name.toLowerCase()
328
+ if (version) allowedVer.add(l + "@" + version.toLowerCase())
329
+ else allowedWide.add(l)
330
+ }
331
+
332
+ const blockedWide = new Set<string>()
333
+ const blockedVer = new Map<string, Set<string>>()
334
+ for (const e of [...BLOCKED_PACKAGES, ...extraBlocked]) {
335
+ const { name, version } = splitNameVersion(e)
336
+ const l = name.toLowerCase()
337
+ if (version) {
338
+ const s = blockedVer.get(l) ?? new Set<string>()
339
+ s.add(version.toLowerCase())
340
+ blockedVer.set(l, s)
341
+ } else {
342
+ blockedWide.add(l)
343
+ }
344
+ }
345
+
346
+ const npmish = new Set(["npm", "yarn", "pnpm", "npx", "bun"])
208
347
  for (const target of targets) {
209
- const npmish = target.manager === "npm" || target.manager === "yarn" || target.manager === "pnpm"
210
348
  for (const pkg of target.packages) {
211
349
  const lower = pkg.name.toLowerCase()
212
- if (ok.has(lower)) continue
213
- if (blocked.has(lower)) {
350
+ const ver = pkg.version ? pkg.version.toLowerCase() : undefined
351
+ if (allowedWide.has(lower)) continue
352
+ // a versioned allow entry must NOT lift a name-wide block (spec: it lifts only versioned blocks)
353
+ if (blockedWide.has(lower)) {
214
354
  return { name: pkg.name, reason: "blocklist" }
215
355
  }
216
- // squat check is npm-only: other ecosystems reuse the same names legitimately (pip "requests" vs npm "request")
217
- if (npmish) {
218
- const squat = isTyposquat(pkg.name)
356
+ if (ver && allowedVer.has(lower + "@" + ver)) continue
357
+ if (ver) {
358
+ const s = blockedVer.get(lower)
359
+ if (s?.has(ver)) {
360
+ return { name: pkg.name, reason: "blocklist" }
361
+ }
362
+ }
363
+ // squat checks are npm-ecosystem-only: other ecosystems reuse the same names legitimately (pip "requests" vs npm "request")
364
+ if (npmish.has(target.manager)) {
365
+ // affix first: "lodash-js" is affix of lodash (exact form), not a dist-1 edit of lodash-es
366
+ const squat = isAffixSquat(pkg.name) ?? isTyposquat(pkg.name)
219
367
  if (squat) {
220
368
  return { name: pkg.name, reason: "typosquat", target: squat }
221
369
  }
@@ -224,3 +372,49 @@ export function checkInstall(command: string, extraBlocked: readonly string[] =
224
372
  }
225
373
  return null
226
374
  }
375
+
376
+ /** true if any segment is an npm install (drives the post-audit scheduler; npx/dlx don't count) */
377
+ export function isNpmInstall(command: string): boolean {
378
+ return parseSegments(command).some((t) => t.manager === "npm")
379
+ }
380
+
381
+ export interface AuditSummary {
382
+ total: number
383
+ severities: Record<string, number>
384
+ }
385
+
386
+ /** npm audit severity vocabulary; anything else folds into "unknown" (blocks __proto__-style key damage) */
387
+ const SEVERITY_NAMES = new Set(["info", "low", "moderate", "high", "critical"])
388
+
389
+ /** aggregate `npm audit --json` output (v2 auditReport.vulnerabilities or v1 advisories); null if unusable */
390
+ export function parseAuditReport(text: string): AuditSummary | null {
391
+ let json: unknown
392
+ try {
393
+ json = JSON.parse(text)
394
+ } catch {
395
+ return null
396
+ }
397
+ if (typeof json !== "object" || json === null) return null
398
+ const obj = json as Record<string, unknown>
399
+ const sources: unknown[] = []
400
+ if (typeof obj.auditReport === "object" && obj.auditReport !== null) {
401
+ sources.push((obj.auditReport as Record<string, unknown>).vulnerabilities)
402
+ }
403
+ if (typeof obj.advisories === "object" && obj.advisories !== null) {
404
+ sources.push(obj.advisories)
405
+ }
406
+ const found = sources.filter((s) => typeof s === "object" && s !== null && !Array.isArray(s))
407
+ if (found.length === 0) return null
408
+ const severities: Record<string, number> = {}
409
+ let total = 0
410
+ for (const s of found) {
411
+ for (const entry of Object.values(s as Record<string, unknown>)) {
412
+ if (typeof entry !== "object" || entry === null) continue
413
+ const sev = (entry as Record<string, unknown>).severity
414
+ const key = typeof sev === "string" && SEVERITY_NAMES.has(sev) ? sev : "unknown"
415
+ severities[key] = (severities[key] ?? 0) + 1
416
+ total++
417
+ }
418
+ }
419
+ return { total, severities }
420
+ }
package/src/guard.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Error as ToolError } from "@opencode/plugin/promise/tool"
2
2
  import type { Plugin } from "@opencode/plugin"
3
3
  import { createHash } from "node:crypto"
4
+ import { spawn } from "node:child_process"
4
5
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
5
6
  import { homedir } from "node:os"
6
7
  import { join } from "node:path"
@@ -10,7 +11,7 @@ import { isPathBlocked, isPathExempt, isPathWriteProtected } from "./glob.ts"
10
11
  import type { GuardConfig } from "./config.ts"
11
12
  import { appendAudit, type AuditEvent } from "./audit.ts"
12
13
  import { scanSkillDir } from "./skillcheck.ts"
13
- import { checkInstall } from "./deps.ts"
14
+ import { checkInstall, isNpmInstall, parseAuditReport } from "./deps.ts"
14
15
 
15
16
  type Ctx = Plugin.Context
16
17
 
@@ -140,6 +141,81 @@ const tracked: { dispose(): Promise<void> }[] = []
140
141
  const DEPS_AUDIT_TTL_MS = 2000
141
142
  const depsAuditRecent = new Map<string, number>()
142
143
 
144
+ /** post-audit: give the install ~60s to finish, then npm audit in the project dir (warn-only) */
145
+ const POST_AUDIT_DELAY_MS = 60_000
146
+ const POST_AUDIT_TIMEOUT_MS = 120_000
147
+ /** don't stack audits for the same project dir (a burst of installs schedules one audit) */
148
+ const postAuditRecent = new Map<string, number>()
149
+ const POST_AUDIT_DEDUP_MS = 5 * 60_000
150
+
151
+ function schedulePostAudit(command: string, cwd: string, cfg: GuardConfig): void {
152
+ try {
153
+ if (!isNpmInstall(command)) return
154
+ const last = postAuditRecent.get(cwd)
155
+ if (last && Date.now() - last < POST_AUDIT_DEDUP_MS) return
156
+ postAuditRecent.set(cwd, Date.now())
157
+ if (postAuditRecent.size > 100) postAuditRecent.clear()
158
+ const timer = setTimeout(() => {
159
+ try {
160
+ const child = spawn("npm", ["audit", "--json"], {
161
+ cwd,
162
+ shell: process.platform === "win32",
163
+ detached: true,
164
+ windowsHide: true,
165
+ })
166
+ let stdout = ""
167
+ child.stdout?.on("data", (d: Buffer) => {
168
+ stdout += d.toString()
169
+ })
170
+ child.on("error", () => {})
171
+ // built-in spawn timeout only kills the win32 cmd.exe wrapper — enforce our own
172
+ let closed = false
173
+ const killer = setTimeout(() => {
174
+ try {
175
+ if (closed) return
176
+ if (process.platform === "win32") {
177
+ if (child.pid === undefined) return
178
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true }).on("error", () => {})
179
+ } else {
180
+ child.kill("SIGTERM")
181
+ }
182
+ } catch {}
183
+ }, POST_AUDIT_TIMEOUT_MS)
184
+ killer.unref?.()
185
+ child.on("close", () => {
186
+ try {
187
+ closed = true
188
+ clearTimeout(killer)
189
+ // partial JSON after a forced kill simply fails to parse — dropped, by design
190
+ const report = parseAuditReport(stdout)
191
+ if (!report || report.total === 0) return
192
+ const sev = Object.entries(report.severities)
193
+ .map(([k, v]) => `${k}:${v}`)
194
+ .join(" ")
195
+ audit(cfg, {
196
+ ts: new Date().toISOString(),
197
+ kind: "deps",
198
+ categories: ["audit-vulnerability"],
199
+ count: report.total,
200
+ action: "warn",
201
+ severities: report.severities,
202
+ })
203
+ console.warn(`guard: npm audit в ${cwd} — ${report.total} уязвимостей (${sev}), детали в аудите`)
204
+ } catch {
205
+ // never crash the plugin over a post-audit result
206
+ }
207
+ })
208
+ child.unref?.()
209
+ } catch {
210
+ // never crash the plugin over scheduling
211
+ }
212
+ }, POST_AUDIT_DELAY_MS)
213
+ timer.unref?.()
214
+ } catch {
215
+ // never crash the hook
216
+ }
217
+ }
218
+
143
219
  export function registerGuard(ctx: Ctx, cfg: GuardConfig): () => Promise<void> {
144
220
  const patterns = activePatterns(cfg.disabledPatterns)
145
221
  const now = () => new Date().toISOString()
@@ -368,9 +444,12 @@ export function registerGuard(ctx: Ctx, cfg: GuardConfig): () => Promise<void> {
368
444
  if (!last || Date.now() - last > DEPS_AUDIT_TTL_MS) {
369
445
  audit(cfg, { ts: now(), kind: "deps", categories: [cat], count: 1, action: mutate ? "blocked" : "warn" })
370
446
  }
447
+ // warn mode lets the install through — it still qualifies for post-audit
448
+ if (!mutate && cfg.postAudit) schedulePostAudit(event.command, event.cwd, cfg)
371
449
  return
372
450
  }
373
451
  }
452
+ if (cfg.postAudit) schedulePostAudit(event.command, event.cwd, cfg)
374
453
  if (!cfg.redactOnWrite) return
375
454
  const r = redact(event.command, patterns)
376
455
  if (r.matches.length > 0) {