@7n/rules 1.84.0 → 1.86.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/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/rules/security/tracked_symlink/concern.json +6 -0
- package/rules/security/tracked_symlink/main.mjs +128 -0
- package/schemas/v8r-catalog.json +1 -1
- package/scripts/lib/lint-surface/wasm-plugins.mjs +55 -15
- package/scripts/lib/resolve-plugins.mjs +1 -1
- package/skills/wasm-plugin/SKILL.md +43 -0
- package/wasm-plugins/builtin-pins.json +0 -6
- package/wasm-plugins/plugin-lang-js.wasm +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.86.0] - 2026-08-05
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Правило security: новий концерн tracked_symlink — repo-wide detector відстежених git-ом symlink-ів, що вказують за межі репозиторію (абсолютний шлях або відносний з виходом вгору за корінь). Ловить рецидив кшталту target -> /private/tmp/... (коміт 867ff7b3): у чужому клоні такий symlink робить збірку й тести недетермінованими, а .gitignore не рятує, бо ignore не покриває вже відстежені шляхи. Фіксу немає — detector друкує готову команду git rm --cached.
|
|
8
|
+
|
|
9
|
+
## [1.85.0] - 2026-08-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Контракт плагінів `n-rules:plugin` — мінор `3.1.0`: новий host-імпорт `exec-tool`
|
|
14
|
+
(`run-tool` плюс `cwd`, накладені змінні середовища й двобічний scratch-обмін
|
|
15
|
+
файлами) і слот `scratch-dir@1` на чинному `host-context`. Зміна additive:
|
|
16
|
+
плагін, зібраний під v3.0, лінкується й працює без повторної збірки.
|
|
17
|
+
|
|
18
|
+
Резолв тулів у `manifest.tools` навчився схем (рішення В спеки v3.1):
|
|
19
|
+
`path:<name>` резолвиться по PATH замість github-релізу; відсутність схеми —
|
|
20
|
+
як було (`pinned:`).
|
|
21
|
+
|
|
22
|
+
Перший концерн на новій поверхні — `bun/licensee`: детекція ліцензій
|
|
23
|
+
npm-залежностей переїхала з JS у wasm-компонент `lang-js/wasm-concerns`
|
|
24
|
+
(T0-фікс лишається JS). Спавнений тул виконується з правами хоста, поза
|
|
25
|
+
wasm-пісочницею — межа довіри плагіна це його пін, не пісочниця.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- регенерує `.cursor/rules/n-rust.mdc` (дрейф із каноном після #392) і піднімає `KNOWN_PLUGIN_RANGES['@7n/rules-lang-rust']` до `^0.16` слідом за релізом плагіна
|
|
30
|
+
|
|
3
31
|
## [1.84.0] - 2026-08-05
|
|
4
32
|
|
|
5
33
|
### Changed
|
package/package.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lint-поверхня security/tracked_symlink: read-only detector symlink-ів, що відстежуються
|
|
3
|
+
* git-ом і вказують ЗА МЕЖІ репозиторію — абсолютним шляхом (`/private/tmp/…`, `C:\…`)
|
|
4
|
+
* або відносним, що виходить вгору за корінь (`../../…`).
|
|
5
|
+
*
|
|
6
|
+
* Навіщо. Такий symlink несе шлях, специфічний для машини автора, у всі клони: у чужому
|
|
7
|
+
* checkout-і збірка пише артефакти кудись у чужу теку (або нікуди), а тести, що шукають
|
|
8
|
+
* артефакти за `<repo>/<path>/…`, стають недетермінованими. Класичний випадок цього репо —
|
|
9
|
+
* коміт 867ff7b3, який приніс `target -> /private/tmp/claude-501/cargo-target-…`;
|
|
10
|
+
* `.gitignore` не рятує, бо ignore не покриває вже відстежені шляхи.
|
|
11
|
+
*
|
|
12
|
+
* Фіксу немає (авто-правка symlink-а — здогадка про намір автора): detector лише сигналить
|
|
13
|
+
* і друкує готову команду прибирання з індексу.
|
|
14
|
+
*/
|
|
15
|
+
import { posix } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
|
|
18
|
+
import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
|
|
19
|
+
import { spawnAsync } from '../../../scripts/utils/spawn-async.mjs'
|
|
20
|
+
|
|
21
|
+
/** Режим symlink-запису в git-індексі (`git ls-files -s`). */
|
|
22
|
+
const SYMLINK_MODE = '120000'
|
|
23
|
+
|
|
24
|
+
/** `<mode> <sha> <stage>\t<path>` — рядок `git ls-files -s -z` (роздільник записів `\0`). */
|
|
25
|
+
const LS_FILES_ENTRY_RE = /^(?<mode>\d{6}) (?<sha>[0-9a-f]+) (?<stage>\d)\t(?<path>.+)$/su
|
|
26
|
+
|
|
27
|
+
/** Windows-шлях із літерою диска (`C:\…`, `C:/…`) — теж абсолютний, хоч `isAbsolute` на POSIX так не вважає. */
|
|
28
|
+
const WINDOWS_DRIVE_RE = /^[a-z]:[/\\]/iu
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Чи є ціль symlink-а абсолютним шляхом у будь-якій із двох конвенцій.
|
|
32
|
+
* Перевірка суто текстова (без доступу до ФС) — щоб вердикт не залежав від
|
|
33
|
+
* машини, на якій виконується lint.
|
|
34
|
+
* @param {string} target ціль symlink-а, як вона записана в git-індексі
|
|
35
|
+
* @returns {boolean} true, якщо ціль абсолютна
|
|
36
|
+
*/
|
|
37
|
+
function isAbsoluteTarget(target) {
|
|
38
|
+
return posix.isAbsolute(target) || WINDOWS_DRIVE_RE.test(target)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Чи виходить відносна ціль symlink-а за корінь репозиторію.
|
|
43
|
+
* Рахується арифметикою шляхів від теки самого symlink-а — без `realpath`, тож
|
|
44
|
+
* результат однаковий у будь-якому checkout-і.
|
|
45
|
+
* @param {string} linkPath posix-relative шлях symlink-а від кореня репо
|
|
46
|
+
* @param {string} target ціль symlink-а з git-індексу (відносний шлях)
|
|
47
|
+
* @returns {boolean} true, якщо ціль лежить поза деревом репо
|
|
48
|
+
*/
|
|
49
|
+
function escapesRepoRoot(linkPath, target) {
|
|
50
|
+
// Корінь навмисно синтетичний: обчислення не торкається ФС, тож той самий symlink
|
|
51
|
+
// дає той самий вердикт на будь-якій машині й у будь-якому checkout-і.
|
|
52
|
+
const root = '/repo'
|
|
53
|
+
const resolved = posix.resolve(root, posix.dirname(linkPath), target)
|
|
54
|
+
return resolved !== root && !resolved.startsWith(`${root}/`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Розбирає `\0`-розділений вивід `git ls-files -s -z` у symlink-записи.
|
|
59
|
+
* @param {string} stdout сирий stdout `git ls-files -s -z`
|
|
60
|
+
* @returns {{ sha: string, path: string }[]} лише записи з режимом 120000
|
|
61
|
+
*/
|
|
62
|
+
function parseSymlinkEntries(stdout) {
|
|
63
|
+
/** @type {{ sha: string, path: string }[]} */
|
|
64
|
+
const entries = []
|
|
65
|
+
for (const record of stdout.split('\0')) {
|
|
66
|
+
if (record.length === 0) continue
|
|
67
|
+
const groups = LS_FILES_ENTRY_RE.exec(record)?.groups
|
|
68
|
+
if (!groups || groups.mode !== SYMLINK_MODE) continue
|
|
69
|
+
entries.push({ sha: groups.sha, path: posix.normalize(groups.path) })
|
|
70
|
+
}
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Detector security/tracked_symlink: жоден відстежений symlink не має вказувати за межі репо.
|
|
76
|
+
* Поза git-робочим деревом (немає `git` у PATH або каталог не є репо) перевірка пропускається —
|
|
77
|
+
* її предмет, індекс git, там просто відсутній.
|
|
78
|
+
* @param {import('../../../scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст lint-прогону
|
|
79
|
+
* @returns {Promise<import('../../../scripts/lib/lint-surface/types.mjs').LintResult>} результат detector-а
|
|
80
|
+
*/
|
|
81
|
+
export async function lint(ctx) {
|
|
82
|
+
const reporter = createViolationReporter(ctx)
|
|
83
|
+
const { fail } = reporter
|
|
84
|
+
const cwd = ctx.cwd
|
|
85
|
+
|
|
86
|
+
const git = resolveCmd('git')
|
|
87
|
+
if (!git) {
|
|
88
|
+
return {
|
|
89
|
+
violations: [],
|
|
90
|
+
diagnostics: [
|
|
91
|
+
{ level: 'info', message: 'security/tracked_symlink: `git` не знайдено в PATH — перевірку пропущено' }
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const listed = await spawnAsync(git, ['ls-files', '-s', '-z'], { cwd, signal: ctx.signal })
|
|
97
|
+
if (listed.exitCode !== 0) {
|
|
98
|
+
return {
|
|
99
|
+
violations: [],
|
|
100
|
+
diagnostics: [{ level: 'info', message: 'security/tracked_symlink: не git-робоче дерево — перевірку пропущено' }]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const entries = parseSymlinkEntries(listed.stdout)
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
// Ціль читається з git-індексу, а не через readlink робочого дерева: перевіряємо
|
|
107
|
+
// саме те, що поїде у чужий клон. Symlink-ів у здоровому дереві одиниці, тож
|
|
108
|
+
// послідовний cat-file на запис дешевший за розбір бінарного `cat-file --batch`.
|
|
109
|
+
const blob = await spawnAsync(git, ['cat-file', 'blob', entry.sha], { cwd, signal: ctx.signal })
|
|
110
|
+
if (blob.exitCode !== 0) continue
|
|
111
|
+
const target = blob.stdout.replace(/\n+$/u, '')
|
|
112
|
+
if (target.length === 0) continue
|
|
113
|
+
|
|
114
|
+
const absolute = isAbsoluteTarget(target)
|
|
115
|
+
if (!absolute && !escapesRepoRoot(entry.path, target)) continue
|
|
116
|
+
|
|
117
|
+
const kind = absolute ? 'абсолютний шлях' : 'шлях за межами репо'
|
|
118
|
+
fail(
|
|
119
|
+
`security/tracked_symlink: відстежений symlink \`${entry.path}\` → \`${target}\` (${kind}) — ` +
|
|
120
|
+
'у чужому клоні він указує в неіснуючу або чужу теку, роблячи збірку й тести недетермінованими. ' +
|
|
121
|
+
`Прибери його з індексу: \`git rm --cached ${entry.path}\` і додай \`${entry.path}\` у .gitignore ` +
|
|
122
|
+
'(патерн БЕЗ кінцевого слеша — git не ходить за symlink-ом і бачить його як file-entry)',
|
|
123
|
+
{ reason: absolute ? 'absolute-target' : 'escapes-repo', file: entry.path, data: { target } }
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return reporter.result()
|
|
128
|
+
}
|
package/schemas/v8r-catalog.json
CHANGED
|
@@ -208,7 +208,7 @@
|
|
|
208
208
|
},
|
|
209
209
|
{
|
|
210
210
|
"name": "n-rules plugin manifest",
|
|
211
|
-
"description": "plugin.toml — маніфест wasm-плагіна n-rules:plugin@3.
|
|
211
|
+
"description": "plugin.toml — маніфест wasm-плагіна n-rules:plugin@3.1.0 (crates/<guest>/plugin.toml); офіційної JSON Schema поки нема (schemars-схема rules_contract::manifest::Manifest ще не публікується як окремий файл), тому permissive any.json",
|
|
212
212
|
"url": "any.json",
|
|
213
213
|
"fileMatch": [
|
|
214
214
|
"plugin.toml",
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* (значення — НЕ голий рядок шляху, доккомент [`buildWasmConcernMap`] нижче).
|
|
8
8
|
*
|
|
9
9
|
* **Run-tool контур (задача N1, рішення Д спеки)**: `manifest.tools` —
|
|
10
|
-
* задекларовані зовнішні tool-залежності плагіна (напр. `"shellcheck@^0.9"
|
|
10
|
+
* задекларовані зовнішні tool-залежності плагіна (напр. `"shellcheck@^0.9"`,
|
|
11
|
+
* `"path:bun"` — схеми резолву, рішення В спеки
|
|
12
|
+
* `docs/specs/2026-08-01-plugin-contract-v31-surfaces.md`, [`parseToolRef`]).
|
|
11
13
|
* Для кожного запису резолвер кличе ensure-tool контур (`ensureToolAsync`,
|
|
12
14
|
* `../ensure-tool.mjs`, injectable через `opts.ensureToolFn`) — будує мапу
|
|
13
15
|
* «ім'я тула (без semver-суфікса декларації) → абсолютний шлях», яку
|
|
@@ -114,6 +116,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
114
116
|
|
|
115
117
|
import { ensureToolAsync } from '../ensure-tool.mjs'
|
|
116
118
|
import { loadNative } from '../native.mjs'
|
|
119
|
+
import { resolveCmd } from '../../utils/resolve-cmd.mjs'
|
|
117
120
|
|
|
118
121
|
/**
|
|
119
122
|
* Абсолютний шлях до `npm/wasm-plugins/` — тека, куди `build-wasm-plugins.mjs`
|
|
@@ -431,16 +434,39 @@ async function resolveEntryPath(entry, ctx) {
|
|
|
431
434
|
}
|
|
432
435
|
|
|
433
436
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
* (`
|
|
437
|
-
*
|
|
438
|
-
*
|
|
437
|
+
* Схеми резолву тула в рядку `manifest.tools` (рішення В спеки
|
|
438
|
+
* `docs/specs/2026-08-01-plugin-contract-v31-surfaces.md`). Дзеркало
|
|
439
|
+
* Rust-боку (`rules_contract::validators::tool::ToolScheme`) — і саме
|
|
440
|
+
* ДЗЕРКАЛО, а не незалежний список: розбіжність означала б, що маніфест,
|
|
441
|
+
* прийнятий хостом, мовчки не резолвиться оркестрацією.
|
|
442
|
+
*/
|
|
443
|
+
const TOOL_SCHEMES = {
|
|
444
|
+
/** Github-реліз із закріпленою версією (`TOOLS` + `tool-pins.json`) — дефолт за відсутності схеми. */
|
|
445
|
+
PINNED: 'pinned',
|
|
446
|
+
/** Резолв по `PATH` (`bun`, `bunx` — ensure-tool не вміє і не має їх завантажувати). */
|
|
447
|
+
PATH: 'path'
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Розбирає запис `manifest.tools` у `{ scheme, name }`: відрізає схему
|
|
452
|
+
* (якщо є), потім semver-суфікс декларації (`"path:bun@^1.2"` →
|
|
453
|
+
* `{ scheme: 'path', name: 'bun' }`). Порт
|
|
454
|
+
* `rules_contract::validators::tool::parse_tool_ref` — дзеркальний тест
|
|
455
|
+
* конвенції: `tests/wasm-plugins.test.mjs`.
|
|
456
|
+
*
|
|
457
|
+
* Невідома схема НЕ інтерпретується як частина імені: [`ensureDeclaredTools`]
|
|
458
|
+
* пропускає такий запис із warn, бо резолвити його однаково нема чим (та
|
|
459
|
+
* сама поведінка, що host-валідатор `validate_tool_ref`).
|
|
439
460
|
* @param {string} declared запис із `manifest.tools`
|
|
440
|
-
* @returns {string} ім'я тула
|
|
461
|
+
* @returns {{ scheme: string | null, name: string }} схема (`null` — невідома) й ім'я тула
|
|
441
462
|
*/
|
|
442
|
-
function
|
|
443
|
-
|
|
463
|
+
function parseToolRef(declared) {
|
|
464
|
+
const separator = declared.indexOf(':')
|
|
465
|
+
if (separator === -1) return { scheme: TOOL_SCHEMES.PINNED, name: declared.split('@', 1)[0] }
|
|
466
|
+
const prefix = declared.slice(0, separator)
|
|
467
|
+
const rest = declared.slice(separator + 1)
|
|
468
|
+
if (prefix !== TOOL_SCHEMES.PINNED && prefix !== TOOL_SCHEMES.PATH) return { scheme: null, name: declared }
|
|
469
|
+
return { scheme: prefix, name: rest.split('@', 1)[0] }
|
|
444
470
|
}
|
|
445
471
|
|
|
446
472
|
/**
|
|
@@ -456,14 +482,26 @@ function toolName(declared) {
|
|
|
456
482
|
* @param {string} pluginName ім'я плагіна (лише для diagnostics-повідомлень)
|
|
457
483
|
* @param {string[]} declaredTools `manifest.tools` — рядки виду `"shellcheck@^0.9"`
|
|
458
484
|
* @param {(toolId: string) => Promise<string>} ensureToolFn ін'єкція `ensureToolAsync` (тести підміняють)
|
|
485
|
+
* @param {(cmd: string) => string | null} resolveCmdFn ін'єкція `resolveCmd` для схеми `path:` (тести підміняють — інакше результат залежав би від PATH машини)
|
|
459
486
|
* @returns {Promise<Record<string, string>>} ім'я тула (без semver-суфікса) → абсолютний шлях
|
|
460
487
|
*/
|
|
461
|
-
async function ensureDeclaredTools(pluginName, declaredTools, ensureToolFn) {
|
|
488
|
+
async function ensureDeclaredTools(pluginName, declaredTools, ensureToolFn, resolveCmdFn) {
|
|
462
489
|
/** @type {Record<string, string>} */
|
|
463
490
|
const toolPaths = {}
|
|
464
491
|
for (const declared of declaredTools) {
|
|
465
|
-
const name =
|
|
492
|
+
const { scheme, name } = parseToolRef(declared)
|
|
466
493
|
try {
|
|
494
|
+
if (scheme === null)
|
|
495
|
+
throw new Error(`невідома схема резолву (відомі "${TOOL_SCHEMES.PINNED}:", "${TOOL_SCHEMES.PATH}:")`)
|
|
496
|
+
if (scheme === TOOL_SCHEMES.PATH) {
|
|
497
|
+
// `path:` НЕ йде в ensure-tool контур узагалі: той уміє лише
|
|
498
|
+
// github-релізи, а `bun`/`bunx` встановлює користувач. Відсутність —
|
|
499
|
+
// не помилка резолву плагіна, а звичайний skip одного тула.
|
|
500
|
+
const fromPath = resolveCmdFn(name)
|
|
501
|
+
if (!fromPath) throw new Error('не знайдено в PATH')
|
|
502
|
+
toolPaths[name] = fromPath
|
|
503
|
+
continue
|
|
504
|
+
}
|
|
467
505
|
toolPaths[name] = await ensureToolFn(name)
|
|
468
506
|
} catch (error) {
|
|
469
507
|
console.warn(
|
|
@@ -484,7 +522,7 @@ async function ensureDeclaredTools(pluginName, declaredTools, ensureToolFn) {
|
|
|
484
522
|
* запис у мапу для кожного `manifest.concerns`. Усі кроки — skip-not-crash
|
|
485
523
|
* (доккомент модуля).
|
|
486
524
|
* @param {string} cwd абсолютний корінь consumer-репо
|
|
487
|
-
* @param {{fetchFn: typeof fetch, cacheDir: string, env: Record<string, string | undefined>, ensureToolFn: (toolId: string) => Promise<string>, nativeFn: typeof loadNative, builtinPinsDir: string}} ctx ін'єктовані залежності
|
|
525
|
+
* @param {{fetchFn: typeof fetch, cacheDir: string, env: Record<string, string | undefined>, ensureToolFn: (toolId: string) => Promise<string>, resolveCmdFn: (cmd: string) => string | null, nativeFn: typeof loadNative, builtinPinsDir: string}} ctx ін'єктовані залежності
|
|
488
526
|
* @returns {Promise<Map<string, WasmConcernMapEntry>>} ключ концерну → `{ wasmPath, toolPaths }`
|
|
489
527
|
*/
|
|
490
528
|
async function buildWasmConcernMap(cwd, ctx) {
|
|
@@ -500,7 +538,7 @@ async function buildWasmConcernMap(cwd, ctx) {
|
|
|
500
538
|
console.warn(`⚠️ wasm-плагін "${entry.name}" пропущено: не вдалось завантажити (${error.message})`)
|
|
501
539
|
continue
|
|
502
540
|
}
|
|
503
|
-
const toolPaths = await ensureDeclaredTools(entry.name, manifest.tools ?? [], ctx.ensureToolFn)
|
|
541
|
+
const toolPaths = await ensureDeclaredTools(entry.name, manifest.tools ?? [], ctx.ensureToolFn, ctx.resolveCmdFn)
|
|
504
542
|
// `manifest.concerns` — масив структурованих контрибуцій `{ key, scope, glob }`
|
|
505
543
|
// (задача N2, передумова full-scope мосту, доккомент `wit/world.wit`
|
|
506
544
|
// `record concern-contribution`), не голі рядки — мапа концернів індексується
|
|
@@ -521,9 +559,10 @@ async function buildWasmConcernMap(cwd, ctx) {
|
|
|
521
559
|
* неминуче асинхронні; єдиний виклик-сайт (`detect.mjs`) вже `async`,
|
|
522
560
|
* контракт виклику не ламається.
|
|
523
561
|
* @param {string} cwd абсолютний корінь consumer-репо (звідки читається `.n-rules.json`)
|
|
524
|
-
* @param {{fetchFn?: typeof fetch, cacheDir?: string, env?: Record<string, string | undefined>, ensureToolFn?: (toolId: string) => Promise<string>, nativeFn?: typeof loadNative, builtinPinsDir?: string}} [opts] ін'єкції для тестів:
|
|
562
|
+
* @param {{fetchFn?: typeof fetch, cacheDir?: string, env?: Record<string, string | undefined>, ensureToolFn?: (toolId: string) => Promise<string>, resolveCmdFn?: (cmd: string) => string | null, nativeFn?: typeof loadNative, builtinPinsDir?: string}} [opts] ін'єкції для тестів:
|
|
525
563
|
* `fetchFn` (дефолт — глобальний `fetch`), `cacheDir` (дефолт — `resolvePluginCacheDir`), `env` (дефолт — `process.env`),
|
|
526
|
-
* `ensureToolFn` (дефолт — `ensureToolAsync`), `
|
|
564
|
+
* `ensureToolFn` (дефолт — `ensureToolAsync`), `resolveCmdFn` (дефолт — `resolveCmd`; тести підміняють, бо інакше
|
|
565
|
+
* `path:`-схема давала б різний `toolPaths` залежно від PATH машини), `nativeFn` (дефолт — `loadNative`, wiring-тести підміняють фейковим addon-ом),
|
|
527
566
|
* `builtinPinsDir` (дефолт — [`WASM_PLUGINS_DIR`], реальна `npm/wasm-plugins/`; тести ізолюють неіснуючим каталогом,
|
|
528
567
|
* щоб локальна wasm-збірка в робочому дереві не підмішувала builtin-контрибуції в контрольовані сценарії)
|
|
529
568
|
* @returns {Promise<Map<string, WasmConcernMapEntry>>} ключ концерну (`ruleId/concernId`) → `{ wasmPath, toolPaths }`
|
|
@@ -536,6 +575,7 @@ export function resolveWasmConcernMap(cwd, opts = {}) {
|
|
|
536
575
|
cacheDir: opts.cacheDir ?? resolvePluginCacheDir(env),
|
|
537
576
|
env,
|
|
538
577
|
ensureToolFn: opts.ensureToolFn ?? ensureToolAsync,
|
|
578
|
+
resolveCmdFn: opts.resolveCmdFn ?? resolveCmd,
|
|
539
579
|
nativeFn: opts.nativeFn ?? loadNative,
|
|
540
580
|
builtinPinsDir: opts.builtinPinsDir ?? WASM_PLUGINS_DIR
|
|
541
581
|
}
|
|
@@ -283,7 +283,7 @@ export const KNOWN_PLUGIN_RANGES = Object.freeze({
|
|
|
283
283
|
'@7n/rules-ci-azure': '^2',
|
|
284
284
|
'@7n/rules-lang-js': '^0.26',
|
|
285
285
|
'@7n/rules-lang-python': '^0.12',
|
|
286
|
-
'@7n/rules-lang-rust': '^0.
|
|
286
|
+
'@7n/rules-lang-rust': '^0.16',
|
|
287
287
|
'@7n/rules-lang-php': '^0.3'
|
|
288
288
|
})
|
|
289
289
|
|
|
@@ -441,6 +441,49 @@ per-file: пиши `detect_whole_batch(files: &[SourceFile]) -> Vec<Diagnostic>`
|
|
|
441
441
|
фейковий бінарник (shell-скрипт у tempdir) у `ToolResolver::new(map)`
|
|
442
442
|
(зразок — `run_tool_reaches_resolved_fake_tool_binary`
|
|
443
443
|
`crates/rules-plugin-host/tests/contract_test_kit.rs`).
|
|
444
|
+
- **`exec-tool`** (зріз 5 контракту v3.1, спека
|
|
445
|
+
`docs/specs/2026-08-01-plugin-contract-v31-surfaces.md`, рішення А/Б/В/З) —
|
|
446
|
+
`run-tool` ПЛЮС виконавчий контекст. Бери його, якщо тулу потрібне бодай
|
|
447
|
+
щось із трьох:
|
|
448
|
+
- **`cwd`** (posix-relative від кореня репо; `none` — сам корінь). Без
|
|
449
|
+
нього процес успадковує cwd ХОСТ-процесу, який збігається з коренем репо
|
|
450
|
+
випадково. Обгортка, що читає `node_modules`/конфіги відносно себе, без
|
|
451
|
+
цього поля перевіряє не той каталог;
|
|
452
|
+
- **`env`** — пари НАКЛАДАЮТЬСЯ поверх успадкованого середовища, не
|
|
453
|
+
заміщують його;
|
|
454
|
+
- **scratch-обмін**: `scratch-in` хост матеріалізує у тимчасовому каталозі
|
|
455
|
+
ПЕРЕД спавном (вшиті в компонент конфіги/політики), `scratch-out` —
|
|
456
|
+
глоби файлів, які хост зчитує ПІСЛЯ виходу процесу (звіти тула).
|
|
457
|
+
Абсолютний шлях каталогу гість бере зі слоту `scratch-dir@1`
|
|
458
|
+
(`host-context`) і САМ кладе його в `args` — placeholder-підстановки в
|
|
459
|
+
аргументах контракт свідомо не має. Каталог живе РІВНО один
|
|
460
|
+
`detect`/`fix`-виклик, тож шлях не можна кешувати між викликами;
|
|
461
|
+
відсутній `scratch-out`-файл — не помилка, а «звіту немає».
|
|
462
|
+
|
|
463
|
+
Помилки — та сама форма, що в `run-tool`: `status: none` + людиночитний
|
|
464
|
+
`stderr`, ніколи не паніка. `exec-tool` виконує ВЖЕ зарезолвлений тул і
|
|
465
|
+
нічого не добуває — провізіонінг живе в окремій команді `tools ensure`.
|
|
466
|
+
|
|
467
|
+
**Схеми `manifest.tools`** (рішення В): `pinned:` (дефолт за відсутності
|
|
468
|
+
схеми — github-реліз із закріпленою версією) і `path:` (резолв по `PATH`:
|
|
469
|
+
`path:bun`, `path:bunx` — те, що ensure-tool завантажити не може й не має).
|
|
470
|
+
|
|
471
|
+
> **Trust boundary — прочитай ДО того, як напишеш перший `exec-tool`.**
|
|
472
|
+
> Спавнений тул виконується **з правами хоста, ПОЗА wasm-пісочницею**, і
|
|
473
|
+
> **МОЖЕ писати в репо**. Ані `fs_read`, ані `network` до нього не
|
|
474
|
+
> застосовуються: пісочниця обмежує wasm-код гостя, а не процес, який гість
|
|
475
|
+
> попросив запустити. Хост звужує лише ЯКИЙ бінарник (задекларований),
|
|
476
|
+
> ЗВІДКИ він стартує (`cwd` — безпечний repo-relative) і ЩО потрапляє у
|
|
477
|
+
> scratch (без `..`, з лімітами). Тобто **межа довіри плагіна — його пін
|
|
478
|
+
> (`url` + `sha256`), а не пісочниця**: ревʼю піна = ревʼю прав.
|
|
479
|
+
> Enforcement не додається свідомо (потрібен був би власний sandbox-шар
|
|
480
|
+
> навколо спавна на трьох ОС).
|
|
481
|
+
|
|
482
|
+
Живий зразок — `bun/licensee` (`crates/plugin-lang-js/src/lib.rs`, секція
|
|
483
|
+
«Зріз 5»); golden-тести з фейковим `bun` —
|
|
484
|
+
`crates/rules-plugin-host/tests/plugin_lang_js.rs`; повний контекст разом
|
|
485
|
+
(cwd + env + scratch) — `test/guest-exec-tool`
|
|
486
|
+
у `crates/test-plugin-guest`.
|
|
444
487
|
- **`ci_artifacts`** (слот `ci.artifact@1`): типізовані записи
|
|
445
488
|
`n-rules:slots` (`crates/rules-contract/src/slots/ci_artifact.rs`) —
|
|
446
489
|
семантичні перевірки (safe-path, id-regex) живуть у
|
|
Binary file
|