feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -0,0 +1,223 @@
|
||||
// Real Archify package audit for the recommended-skill installer.
|
||||
// Downloads the pinned archive, verifies its digest, audits the ZIP structure and
|
||||
// metadata, then imports it through the production skills module against an
|
||||
// isolated Electron userData directory. No package script is ever executed.
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import fsSync from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { unzipSync } from 'fflate'
|
||||
|
||||
const COMMIT = '25cf37080fc8b1dbe758a4efc3935b55f3327083'
|
||||
const REPOSITORY = 'tt-a1i/archify'
|
||||
const PACKAGE_PATH = 'archify.zip'
|
||||
const EXPECTED_SHA256 = '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5'
|
||||
const EXPECTED_BYTES = 5271959
|
||||
const DOWNLOAD_URL = `https://api.github.com/repos/${REPOSITORY}/contents/${PACKAGE_PATH}?ref=${COMMIT}`
|
||||
const EXPECTED_SKILL_NAME = 'archify'
|
||||
const EXPECTED_SKILL_VERSION = '3.2'
|
||||
const EXPECTED_PACKAGE_VERSION = '3.2.0'
|
||||
const EXPECTED_LICENSE = 'MIT'
|
||||
const EXPECTED_FILE_COUNT = 200
|
||||
const EXPECTED_EXPANDED_BYTES = 17812933
|
||||
|
||||
const repository = path.resolve('.')
|
||||
const testRoot = await fs.mkdtemp(path.resolve('.tmp/archify-package-'))
|
||||
const relative = path.relative(path.resolve('.tmp'), testRoot)
|
||||
assert.ok(relative.startsWith('archify-package-') && !relative.includes('..') && !path.isAbsolute(relative))
|
||||
const userData = path.join(testRoot, 'data')
|
||||
const workspace = path.join(testRoot, 'workspace')
|
||||
await Promise.all([fs.mkdir(userData, { recursive: true }), fs.mkdir(workspace, { recursive: true })])
|
||||
|
||||
const checks = []
|
||||
const check = (name, actual, expected) => {
|
||||
assert.deepEqual(actual, expected, name)
|
||||
checks.push(name)
|
||||
}
|
||||
const finding = (name, detail) => checks.push(`${name} :: ${detail}`)
|
||||
const note = (name, detail) => checks.push(`${name} :: ${detail}`)
|
||||
|
||||
async function download() {
|
||||
const local = process.env.YCODE_ARCHIFY_ZIP
|
||||
if (local) {
|
||||
const bytes = await fs.readFile(path.resolve(local))
|
||||
note('archive source', `local file ${path.resolve(local)}`)
|
||||
return bytes
|
||||
}
|
||||
const response = await fetch(DOWNLOAD_URL, { redirect: 'error', signal: AbortSignal.timeout(120000),
|
||||
headers: { Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28' } })
|
||||
assert.equal(response.status, 200, 'pinned archive download must succeed')
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
note('archive source', DOWNLOAD_URL)
|
||||
return bytes
|
||||
}
|
||||
|
||||
const bytes = await download()
|
||||
const archivePath = path.join(testRoot, PACKAGE_PATH)
|
||||
await fs.writeFile(archivePath, bytes)
|
||||
|
||||
check('pinned archive byte length matches the audited package', bytes.length, EXPECTED_BYTES)
|
||||
const actualSha256 = crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
check('pinned archive digest matches the audited package', actualSha256, EXPECTED_SHA256)
|
||||
check('archive is served from the pinned commit URL', DOWNLOAD_URL.includes(COMMIT), true)
|
||||
|
||||
const entries = unzipSync(bytes)
|
||||
const names = Object.keys(entries)
|
||||
const expandedBytes = names.reduce((sum, name) => sum + entries[name].length, 0)
|
||||
check('archive contains the audited file count', names.length, EXPECTED_FILE_COUNT)
|
||||
check('archive expands to the audited size', expandedBytes, EXPECTED_EXPANDED_BYTES)
|
||||
check('every archive entry lives under a single skill directory',
|
||||
names.every(name => name.startsWith('archify/')), true)
|
||||
check('archive has exactly one SKILL.md at the package root',
|
||||
names.filter(name => name === 'archify/SKILL.md').length, 1)
|
||||
check('archive contains no nested second skill',
|
||||
names.filter(name => name.split('/').filter(part => part === 'SKILL.md').length > 1).length, 0)
|
||||
|
||||
const text = (name) => Buffer.from(entries[name]).toString('utf8')
|
||||
const skillMarkdown = text('archify/SKILL.md')
|
||||
const frontMatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMarkdown)
|
||||
assert.ok(frontMatter, 'SKILL.md must carry YAML front matter')
|
||||
const skillMeta = frontMatter[1]
|
||||
check('SKILL.md declares the audited skill name', /(^|\n)name:\s*archify\s*(\n|$)/.test(skillMeta), true)
|
||||
check('SKILL.md declares the audited license', /(^|\n)license:\s*MIT\s*(\n|$)/.test(skillMeta), true)
|
||||
check('SKILL.md declares the audited metadata version', /version:\s*"?3\.2"?/.test(skillMeta), true)
|
||||
check('SKILL.md declares the audited author', /author:\s*tt-a1i/.test(skillMeta), true)
|
||||
|
||||
const packageJson = JSON.parse(text('archify/package.json'))
|
||||
check('package.json declares the audited package name', packageJson.name, EXPECTED_SKILL_NAME)
|
||||
check('package.json declares the audited package version', packageJson.version, EXPECTED_PACKAGE_VERSION)
|
||||
check('package.json declares the audited license', packageJson.license, EXPECTED_LICENSE)
|
||||
check('package.json keeps the package private', packageJson.private, true)
|
||||
const release = JSON.parse(text('archify/skill-release.json'))
|
||||
check('release manifest declares the audited version', release.version, EXPECTED_PACKAGE_VERSION)
|
||||
check('release manifest points at the audited repository', release.source.repository, `https://github.com/${REPOSITORY}`)
|
||||
check('release manifest exposes an update URL that the host must not auto-follow',
|
||||
typeof release.updateManifestUrl === 'string' && release.updateManifestUrl.length > 0, true)
|
||||
check('archive ships the MIT license text', text('archify/LICENSE').includes('MIT License'), true)
|
||||
check('archive ships the third-party notices', names.includes('archify/THIRD_PARTY_NOTICES.md'), true)
|
||||
|
||||
const scriptEntries = names.filter(name => /\.(mjs|cjs|js|ts|sh|ps1|bat|cmd)$/i.test(name))
|
||||
check('archive ships executable helper scripts as inert files', scriptEntries.length > 0, true)
|
||||
note('script entries (not executed)', `${scriptEntries.length} files, e.g. ${scriptEntries.slice(0, 3).join(', ')}`)
|
||||
|
||||
// Import through the production skills module. Only Electron's userData path is
|
||||
// redirected; every other code path is the shipped implementation.
|
||||
const bundled = path.join(testRoot, 'skills-archify.mjs')
|
||||
await build({
|
||||
entryPoints: [path.resolve('src/main/capabilities/skills.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
outfile: bundled,
|
||||
logLevel: 'warning',
|
||||
banner: { js: "import { createRequire as createArchifyTestRequire } from 'node:module';const require=createArchifyTestRequire(import.meta.url);" },
|
||||
plugins: [{
|
||||
name: 'isolated-electron-path',
|
||||
setup(context) {
|
||||
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'archify-test' }))
|
||||
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
|
||||
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:(v)=>Buffer.from(String(v),'utf8'),decryptString:(v)=>Buffer.from(v).toString('utf8')}`
|
||||
}))
|
||||
}
|
||||
}, {
|
||||
name: 'record-child-process',
|
||||
setup(context) {
|
||||
context.onResolve({ filter: /^(node:)?child_process$/ }, () => ({ path: 'child-process', namespace: 'archify-test' }))
|
||||
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
|
||||
contents: `const record=(name)=>globalThis.__archifyChildProcesses.push(name);export const spawn=(...a)=>{record('spawn');throw Error('child process blocked in package audit')};export const spawnSync=(...a)=>{record('spawnSync');throw Error('child process blocked in package audit')};export const exec=(...a)=>{record('exec');throw Error('child process blocked in package audit')};export const execSync=(...a)=>{record('execSync');throw Error('child process blocked in package audit')};export const fork=(...a)=>{record('fork');throw Error('child process blocked in package audit')};export default{spawn,spawnSync,exec,execSync,fork};`
|
||||
}))
|
||||
}
|
||||
}]
|
||||
})
|
||||
globalThis.__archifyChildProcesses = []
|
||||
const bundleSource = await fs.readFile(bundled, 'utf8')
|
||||
check('the production skills module does not spawn a child process during import',
|
||||
globalThis.__archifyChildProcesses.length, 0)
|
||||
|
||||
const api = await import(pathToFileURL(bundled).href)
|
||||
check('the isolated skill library starts empty', await api.listSkills(), [])
|
||||
|
||||
const imported = await api.importSkill(archivePath)
|
||||
check('the pinned archive imports as the audited skill name', imported.name, EXPECTED_SKILL_NAME)
|
||||
check('the imported skill is a user-scoped import', imported.scope, 'user')
|
||||
check('the imported skill is enabled by default', imported.enabled, true)
|
||||
check('the imported skill is not flagged as locally modified', imported.userModified, false)
|
||||
// Archify nests its version under `metadata:` in the YAML front matter. The host
|
||||
// parser only reads a top-level `version`, so record what the host can see and
|
||||
// keep the package manifest as the authoritative version for validation.
|
||||
finding('host-visible skill version', `imported.version=${String(imported.version)} (front matter nests version under metadata:; expected ${EXPECTED_SKILL_VERSION})`)
|
||||
check('the package manifest carries the authoritative version', packageJson.version, EXPECTED_PACKAGE_VERSION)
|
||||
check('the imported skill keeps the audited license text in its body',
|
||||
(await api.readSkill(imported.id)).content.includes('license: MIT'), true)
|
||||
|
||||
const installedPath = path.resolve(imported.path)
|
||||
check('the installed package stays inside the isolated userData directory',
|
||||
installedPath.startsWith(path.resolve(userData) + path.sep), true)
|
||||
check('the installed package never lands in the real user skill library',
|
||||
installedPath.includes(path.join('capabilities', 'skills', 'packages')), true)
|
||||
|
||||
const document = await api.readSkill(imported.id)
|
||||
check('the imported skill exposes every audited file', document.files.length, EXPECTED_FILE_COUNT)
|
||||
// The host strips the single enclosing directory, so installed paths are
|
||||
// relative to the skill root rather than to the archive root.
|
||||
const installedNames = names.map(name => name.replace(/^archify\//, ''))
|
||||
check('the imported file list is relative to the skill root',
|
||||
document.files.every(name => !name.startsWith('archify/')), true)
|
||||
check('the imported file list is sorted and complete',
|
||||
document.files.join('|') === installedNames.slice().sort().join('|'), true)
|
||||
check('the imported skill body is the audited SKILL.md',
|
||||
document.content === skillMarkdown, true)
|
||||
|
||||
const listed = (await api.listSkills(workspace)).find(item => item.id === imported.id)
|
||||
check('the installed skill is listed for the workspace', listed?.name, EXPECTED_SKILL_NAME)
|
||||
check('the installed skill stays unmodified after import', listed?.userModified, false)
|
||||
|
||||
const reference = await api.readSkillResource(imported.id, 'references/repository-authoring.md', workspace)
|
||||
check('a shipped reference resource is readable through the host', reference.length > 0, true)
|
||||
await assert.rejects(api.readSkillResource(imported.id, '../escape.md', workspace), /越界|非法|不安全/)
|
||||
checks.push('resource reads reject traversal outside the package')
|
||||
|
||||
const context = await api.buildSkillContext([imported.id], workspace)
|
||||
check('the explicit skill context carries the real instructions', context.includes('<skill id='), true)
|
||||
check('the explicit skill context lists the package resources', context.includes('bin/archify.mjs'), true)
|
||||
|
||||
// Nothing in the package may run during installation.
|
||||
const workspaceEntries = await fs.readdir(workspace)
|
||||
check('importing the package creates no workspace output', workspaceEntries, [])
|
||||
check('importing the package creates no generated artifacts directory',
|
||||
fsSync.existsSync(path.join(workspace, '.archify')), false)
|
||||
const exampleFiles = []
|
||||
const collectExamples = async (folder, prefix) => {
|
||||
for (const item of await fs.readdir(folder, { withFileTypes: true })) {
|
||||
if (item.isDirectory()) await collectExamples(path.join(folder, item.name), `${prefix}${item.name}/`)
|
||||
else exampleFiles.push(`${prefix}${item.name}`)
|
||||
}
|
||||
}
|
||||
await collectExamples(path.join(installedPath, 'examples'), '')
|
||||
check('importing the package leaves the shipped examples untouched',
|
||||
exampleFiles.length, names.filter(name => name.startsWith('archify/examples/')).length)
|
||||
|
||||
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({
|
||||
result: 'PASS',
|
||||
scope: 'Real pinned GitHub archive; digest and ZIP structure audited; import through the production skills module against an isolated Electron userData directory. No package script is executed.',
|
||||
source: { repository: REPOSITORY, commit: COMMIT, packagePath: PACKAGE_PATH, downloadUrl: DOWNLOAD_URL },
|
||||
archive: { bytes: bytes.length, sha256: actualSha256, fileCount: names.length, expandedBytes },
|
||||
metadata: {
|
||||
skillName: EXPECTED_SKILL_NAME,
|
||||
skillVersion: imported.version,
|
||||
packageVersion: packageJson.version,
|
||||
license: packageJson.license,
|
||||
author: 'tt-a1i',
|
||||
releaseVersion: release.version
|
||||
},
|
||||
checks,
|
||||
limitations: [
|
||||
'The digest and metadata confirm the bytes match the audited package; they are not a security audit of the skill code.',
|
||||
'Package scripts are shipped as inert files and were never executed, so runtime dependencies and rendering behavior remain unverified.',
|
||||
'The update manifest URL inside the package is reported only; the host must not auto-follow it.'
|
||||
]
|
||||
}, null, 2))
|
||||
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot, sha256: actualSha256 }))
|
||||
Reference in new issue
Block a user