diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e017b66e5..caa21ae15 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "Harness-native ECC operator layer - 68 agents, 284 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "description": "Harness-native ECC operator layer - 68 agents, 285 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "version": "2.2.0", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3b348f34b..0a1436d35 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.2.0", - "description": "Harness-native ECC plugin for engineering teams - 68 agents, 284 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", + "description": "Harness-native ECC plugin for engineering teams - 68 agents, 285 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/AGENTS.md b/AGENTS.md index 3c6bf777b..4235ea156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 68 specialized agents, 284 skills, 94 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 68 specialized agents, 285 skills, 94 commands, and automated hook workflows for software development. **Version:** 2.2.0 @@ -154,7 +154,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 68 specialized subagents -skills/ — 284 workflow skills and domain knowledge +skills/ — 285 workflow skills and domain knowledge commands/ — 94 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) diff --git a/README.md b/README.md index 17cd1b201..a88dd810e 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,12 @@ Instead of rebuilding that process in every prompt, you install it once and make ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity. -Access to 68 agents, 284 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. +Access to 68 agents, 285 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. | Included | Count | What it gives you | | ---------------- | ----------: | ------------------------------------------------------------------------------------ | | Agents | 68 agents | Planning, review, build repair, security, architecture, and domain work | -| Skills | 284 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | +| Skills | 285 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | | Commands | 94 commands | Convenient entry points while ECC moves to a skills-first surface | | Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls | | Rules | Selective | Always-loaded standards you choose by language or project | diff --git a/README.zh-CN.md b/README.zh-CN.md index 86728c0cc..0c5647d0d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 68 个代理、284 个技能和 94 个命令。 +**完成!** 你现在可以使用 68 个代理、285 个技能和 94 个命令。 ### multi-* 命令需要额外配置 diff --git a/docs/tr/AGENTS.md b/docs/tr/AGENTS.md index 2c54176f6..6124dff3c 100644 --- a/docs/tr/AGENTS.md +++ b/docs/tr/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Talimatları -Bu, yazılım geliştirme için 68 özel agent, 284 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**. +Bu, yazılım geliştirme için 68 özel agent, 285 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**. **Sürüm:** 2.2.0 @@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl ``` agents/ — 68 özel subagent -skills/ — 284 iş akışı skillleri ve alan bilgisi +skills/ — 285 iş akışı skillleri ve alan bilgisi commands/ — 94 slash command hooks/ — Tetikleyici tabanlı otomasyonlar rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel) diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index fb88d94c3..404cceaca 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、284 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、285 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 2.2.0 @@ -147,7 +147,7 @@ ``` agents/ — 68 个专业子代理 -skills/ — 284 个工作流技能和领域知识 +skills/ — 285 个工作流技能和领域知识 commands/ — 94 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index f13d8aeae..3674c1614 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -260,7 +260,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 68 个智能体、284 项技能和 94 个命令了。 +**搞定!** 你现在可以使用 68 个智能体、285 项技能和 94 个命令了。 *** @@ -1174,7 +1174,7 @@ opencode |---------|---------------|----------|--------| | 智能体 | PASS: 68 个 | PASS: 12 个 | **Claude Code 领先** | | 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** | -| 技能 | PASS: 284 项 | PASS: 37 项 | **Claude Code 领先** | +| 技能 | PASS: 285 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** | @@ -1282,7 +1282,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 |---------|-----------------------|------------|-----------|----------| | **智能体** | 68 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | | **命令** | 94 | 共享 | 基于指令 | 35 | -| **技能** | 284 | 共享 | 10 (原生格式) | 37 | +| **技能** | 285 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 | | **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 | diff --git a/manifests/install-components.json b/manifests/install-components.json index a5f976a94..971f86607 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -202,6 +202,14 @@ "ito-compute" ] }, + { + "id": "capability:nasiko-control-plane", + "family": "capability", + "description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.", + "modules": [ + "nasiko-control-plane" + ] + }, { "id": "capability:social", "family": "capability", diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 8c0ea11d8..f4560b593 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -635,6 +635,36 @@ "cost": "light", "stability": "beta" }, + { + "id": "nasiko-control-plane", + "kind": "skills", + "description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.", + "paths": [ + "skills/nasiko-control-plane" + ], + "targets": [ + "claude", + "claude-project", + "cursor", + "antigravity", + "codex", + "gemini", + "opencode", + "codebuddy", + "joycode", + "qwen", + "zed", + "hermes", + "openclaw", + "kimi" + ], + "dependencies": [ + "platform-configs" + ], + "defaultInstall": false, + "cost": "light", + "stability": "experimental" + }, { "id": "social-distribution", "kind": "skills", diff --git a/manifests/install-profiles.json b/manifests/install-profiles.json index 15e429943..25091775e 100644 --- a/manifests/install-profiles.json +++ b/manifests/install-profiles.json @@ -89,6 +89,7 @@ "optimization-workflows", "prediction-market-skills", "ito-compute", + "nasiko-control-plane", "social-distribution", "media-generation", "orchestration", diff --git a/package.json b/package.json index 56ac663db..c4a8e73cf 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "scripts/install-guided.js", "scripts/install-plan.js", "scripts/ito.js", + "scripts/nasiko.js", "scripts/lib/", "scripts/list-installed.js", "scripts/loop-status.js", @@ -233,6 +234,7 @@ "skills/ito-compute/", "skills/ito-inference/", "skills/ito-training/", + "skills/nasiko-control-plane/", "skills/investor-materials/", "skills/investor-outreach/", "skills/iterative-retrieval/", diff --git a/scripts/ecc.js b/scripts/ecc.js index 3caff5735..8a92fa302 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -39,6 +39,10 @@ const COMMANDS = { script: 'ito.js', description: 'Invoke the separately installed canonical Itô compute CLI', }, + nasiko: { + script: 'nasiko.js', + description: 'Install or inspect the optional pinned Nasiko control-plane CLI', + }, memory: { script: 'memory.js', description: 'Share durable context across Claude, Codex, Hermes, and other harnesses', @@ -110,6 +114,7 @@ const PRIMARY_COMMANDS = [ 'consult', 'control-pane', 'ito', + 'nasiko', 'memory', 'list-installed', 'doctor', @@ -168,6 +173,9 @@ Examples: ecc ito auth ecc ito find --gpu h200 --count 8 --nodes 1 --gpus-per-node 8 --days 30 --storage-tb 1 --start-window 2099-08-15 --max-rate 3.00 --form-factor bare_metal --contract-type reservation --fabric infiniband --region us-east-1 ecc ito status --json + ecc nasiko status --json + ecc nasiko install --version v0.1.0 --dry-run --json + ecc nasiko install --version v0.1.0 --yes --json ecc ito evals --cluster clu_prod_example --live-sixtytwo --nodes gpu-01,gpu-02 --config-dir /absolute/path/to/qualification-config ecc memory init ecc memory handoff --from codex --target claude --title "Continue migration" --stdin diff --git a/scripts/lib/nasiko-release.js b/scripts/lib/nasiko-release.js new file mode 100644 index 000000000..e04bf999f --- /dev/null +++ b/scripts/lib/nasiko-release.js @@ -0,0 +1,340 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); +const zlib = require('zlib'); + +const REGISTRY_ORIGIN = 'https://registry.nasiko.dev'; +const REPOSITORY = 'nasiko/nasiko'; +const SOURCE_URL = 'https://github.com/Nasiko-Labs/nasiko'; +const LICENSE = 'Apache-2.0'; +const METADATA_FILENAME = '.ecc-nasiko-install.json'; +const MAX_MANIFEST_BYTES = 1024 * 1024; +const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; +const MAX_BINARY_BYTES = 64 * 1024 * 1024; +const MAX_METADATA_BYTES = 64 * 1024; +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; + +const QUALIFIED_RELEASES = Object.freeze({ + 'v0.1.0': Object.freeze({ + 'linux/amd64': Object.freeze({ manifestDigest: 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de', binaryDigest: 'sha256:94a2bcab2d3832257e0111480bb7c3dcac81d63a7ae9bac53206a92c0957ee0f' }), + 'linux/arm64': Object.freeze({ manifestDigest: 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249', binaryDigest: 'sha256:85f9fa5cfbed6c276fce6df2d71e9d0c66d7d8e8d46a40297464833d38db7a7e' }), + 'darwin/amd64': Object.freeze({ manifestDigest: 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06', binaryDigest: 'sha256:ed6232e0bb96a2dcfd86d3c25f86021091600d52f09250403a54528bfe8100a3' }), + 'darwin/arm64': Object.freeze({ manifestDigest: 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0', binaryDigest: 'sha256:3c60f862b04eea1b9a633593b39f1a443d9ca2123cf3edfd150d313e95f3894b' }), + 'windows/amd64': Object.freeze({ manifestDigest: 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045', binaryDigest: 'sha256:0f57672d24fc3c70e4cbbf22864e65b35b9978ddaae3793803841536e682929b' }), + }), +}); + +function normalizePlatform(platform = process.platform, architecture = process.arch) { + const osName = platform === 'win32' ? 'windows' : platform; + if (!['linux', 'darwin', 'windows'].includes(osName)) throw new Error(`Unsupported platform: ${platform}`); + const arch = architecture === 'x64' ? 'amd64' : architecture; + if (!['amd64', 'arm64'].includes(arch)) throw new Error(`Unsupported architecture: ${architecture}`); + if (osName === 'windows' && arch !== 'amd64') throw new Error(`Unsupported architecture for Windows: ${architecture}`); + return { os: osName, arch, binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko' }; +} + +function getQualifiedRelease(version, platform = process.platform, architecture = process.arch) { + if (!/^v\d+\.\d+\.\d+$/.test(String(version || ''))) { + throw new Error('Nasiko installation requires a pinned version such as v0.1.0; latest is not allowed.'); + } + const normalized = normalizePlatform(platform, architecture); + const qualification = QUALIFIED_RELEASES[version]?.[`${normalized.os}/${normalized.arch}`]; + if (!qualification) throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`); + return { version, ...normalized, ...qualification, license: LICENSE, sourceUrl: SOURCE_URL }; +} + +function digestBytes(bytes) { + return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`; +} + +function assertDigest(bytes, expectedDigest, label) { + if (!SHA256_PATTERN.test(expectedDigest)) throw new Error(`${label} has an invalid expected digest.`); + const actual = digestBytes(bytes); + if (actual !== expectedDigest) throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actual}.`); +} + +function validateManifest(bytes) { + let manifest; + try { manifest = JSON.parse(bytes.toString('utf8')); } catch (_error) { throw new Error('Nasiko manifest is not valid JSON.'); } + if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.layers) || manifest.layers.length !== 1) { + throw new Error('Nasiko manifest must contain exactly one OCI layer.'); + } + const layer = manifest.layers[0]; + if (layer.mediaType !== 'application/gzip' || !SHA256_PATTERN.test(layer.digest)) { + throw new Error('Nasiko manifest layer is not a qualified gzip artifact.'); + } + if (!Number.isSafeInteger(layer.size) || layer.size <= 0 || layer.size > MAX_ARCHIVE_BYTES) { + throw new Error('Nasiko manifest layer size is outside the allowed range.'); + } + return { digest: layer.digest, size: layer.size }; +} + +function readTarString(block, offset, length) { + return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, ''); +} + +function extractQualifiedTarGzip(archiveBytes, expectedName) { + let tar; + try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); } + catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); } + let offset = 0; + let binary = null; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) break; + const name = readTarString(header, 0, 100); + const prefix = readTarString(header, 345, 155); + const type = String.fromCharCode(header[156] || 48); + const rawSize = readTarString(header, 124, 12).trim(); + const size = Number.parseInt(rawSize || '0', 8); + const start = offset + 512; + const end = start + size; + if (!Number.isSafeInteger(size) || size < 0 || end > tar.length) throw new Error('Nasiko archive is truncated.'); + const payload = tar.subarray(start, end); + const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0'); + const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024; + const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024 + && !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8')); + if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload); + else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); + offset = start + Math.ceil(size / 512) * 512; + } + if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); + return binary; +} + +function fetchBytes(url, options = {}) { + const parsed = new URL(url); + if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') return Promise.reject(new Error('Nasiko download origin is not allowed.')); + const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES; + return new Promise((resolve, reject) => { + const request = https.get(parsed, { headers: options.accept ? { Accept: options.accept } : {} }, response => { + if (response.statusCode >= 300 && response.statusCode < 400) { response.resume(); reject(new Error('Nasiko registry redirects are not allowed.')); return; } + if (response.statusCode !== 200) { response.resume(); reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`)); return; } + const chunks = []; + let total = 0; + response.on('data', chunk => { + total += chunk.length; + if (total > maxBytes) request.destroy(new Error('Nasiko registry response exceeded the size limit.')); + else chunks.push(chunk); + }); + response.on('end', () => resolve(Buffer.concat(chunks))); + response.on('error', reject); + }); + request.setTimeout(options.timeoutMs || 15000, () => request.destroy(new Error('Nasiko registry request timed out.'))); + request.on('error', reject); + }); +} + +function defaultInstallDirectory(normalized, environment = process.env, homeDirectory = os.homedir()) { + if (normalized.os === 'windows') { + if (!environment.LOCALAPPDATA) throw new Error('LOCALAPPDATA is required on Windows.'); + return path.join(environment.LOCALAPPDATA, 'nasiko', 'bin'); + } + return path.join(homeDirectory, '.local', 'bin'); +} + +function validateInstallDirectory(directory) { + if (typeof directory !== 'string' || directory.includes('\0') || !path.isAbsolute(directory)) throw new Error('Nasiko install directory must be an absolute path.'); + if (/^(?:\\\\|\\\\\?\\|\\\\\.\\)/.test(directory)) throw new Error('Nasiko install directory must be on a local filesystem.'); + const resolved = path.resolve(directory); + if (resolved === path.parse(resolved).root) throw new Error('Nasiko cannot install directly into a filesystem root.'); + let ancestor = resolved; + while (!fs.existsSync(ancestor)) { + const parent = path.dirname(ancestor); + if (parent === ancestor) throw new Error('Nasiko install directory has no resolvable filesystem ancestor.'); + ancestor = parent; + } + const canonical = fs.realpathSync(ancestor); + return path.join(canonical, path.relative(ancestor, resolved)); +} + +function assertPrivateInstallDirectory(directory) { + const stats = fs.lstatSync(directory); + if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error('Nasiko install directory must be a real directory, not a symlink.'); + if (process.platform !== 'win32') { + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) throw new Error('Nasiko install directory must be owned by the current user.'); + if ((stats.mode & 0o022) !== 0) throw new Error('Nasiko install directory must not be group- or world-writable.'); + } +} + +function metadataPathFor(executable) { return path.join(path.dirname(executable), METADATA_FILENAME); } + +function readBoundedRegularFile(filePath, maximumBytes) { + const noFollow = fs.constants.O_NOFOLLOW; + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (noFollow || 0)); + try { + const stats = fs.fstatSync(descriptor); + if (!stats.isFile() || stats.size <= 0 || stats.size > maximumBytes) return null; + if (!noFollow) { + const pathStats = fs.lstatSync(filePath); + if (pathStats.isSymbolicLink() + || pathStats.dev !== stats.dev + || pathStats.ino !== stats.ino + || pathStats.birthtimeMs !== stats.birthtimeMs) { + const error = new Error('Nasiko managed files must not be symbolic links or reparse points.'); + error.code = 'ELOOP'; + throw error; + } + } + const bytes = Buffer.allocUnsafe(stats.size); + let total = 0; + while (total < bytes.length) { + const count = fs.readSync(descriptor, bytes, total, bytes.length - total, total); + if (count === 0) return null; + total += count; + } + if (fs.fstatSync(descriptor).size !== stats.size) return null; + return bytes; + } finally { fs.closeSync(descriptor); } +} + +function readMetadata(executable) { + try { + const bytes = readBoundedRegularFile(metadataPathFor(executable), MAX_METADATA_BYTES); + return bytes ? JSON.parse(bytes.toString('utf8')) : null; + } + catch (_error) { return null; } +} + +function inspectInstalledNasiko(executable, resolveRelease = getQualifiedRelease) { + if (!executable) return { installed: false, qualified: false, version: null, executable: null }; + let binary; + try { binary = readBoundedRegularFile(executable, MAX_BINARY_BYTES); } + catch (error) { + if (error.code === 'ENOENT') return { installed: false, qualified: false, version: null, executable }; + if (error.code === 'ELOOP') throw new Error('Nasiko executable must be a regular file, not a symlink.'); + throw error; + } + if (!binary) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) }; + const binaryDigest = digestBytes(binary); + const metadata = readMetadata(executable); + let release = null; + try { if (metadata) release = resolveRelease(metadata.version, metadata.platform, metadata.architecture); } catch (_error) { release = null; } + const qualified = Boolean(release + && metadata.installedPath === executable + && metadata.manifestDigest === release.manifestDigest + && metadata.binaryDigest === release.binaryDigest + && binaryDigest === release.binaryDigest + && metadata.license === release.license + && metadata.sourceUrl === release.sourceUrl); + return { installed: true, qualified, version: qualified ? metadata.version : null, executable, binaryDigest, metadataPath: metadataPathFor(executable) }; +} + +function writeMetadataExclusive(metadataPath, metadata) { + fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); +} + +function acquireLifecycleLock(installDirectory, fileSystem = fs) { + const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock'); + let descriptor; + try { + descriptor = fileSystem.openSync(lockPath, 'wx', 0o600); + fileSystem.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`); + fileSystem.fsyncSync(descriptor); + } + catch (error) { + if (error.code === 'EEXIST') throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`); + if (descriptor !== undefined) { + try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); } + } + throw error; + } + return () => { + try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); } + }; +} + +async function installNasiko(options = {}, dependencies = {}) { + const version = options.version || 'v0.1.0'; + const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); + const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base; + const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); + const destination = path.join(installDirectory, release.binaryName); + const plan = { dryRun: Boolean(options.dryRun), version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.'); + fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 }); + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + const metadataPath = metadataPathFor(destination); + let destinationOwned = false; + let metadataOwned = false; + try { + const existing = inspectInstalledNasiko(destination); + if (existing.installed) { + if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true }; + throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); + } + const retrieve = dependencies.fetchBytes || fetchBytes; + const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES }); + assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); + const layer = validateManifest(manifestBytes); + const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES }); + if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); + assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); + const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName); + assertDigest(binary, release.binaryDigest, 'Nasiko binary'); + if (dependencies.beforePublish) dependencies.beforePublish(destination); + const descriptor = fs.openSync(destination, 'wx', 0o700); + destinationOwned = true; + try { + fs.writeFileSync(descriptor, binary); + fs.fsyncSync(descriptor); + if (fs.fstatSync(descriptor).size !== binary.length) throw new Error('Published Nasiko binary size mismatch.'); + } finally { fs.closeSync(descriptor); } + const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; + (dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata); + metadataOwned = true; + return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest }; + } catch (error) { + if (metadataOwned) fs.rmSync(metadataPath, { force: true }); + if (destinationOwned) fs.rmSync(destination, { force: true }); + throw error; + } finally { releaseLock(); } +} + +function uninstallNasiko(options = {}, dependencies = {}) { + const version = options.version || 'v0.1.0'; + const release = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); + const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); + const destination = path.join(installDirectory, release.binaryName); + const plan = { dryRun: Boolean(options.dryRun), version, destination }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.'); + if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false }; + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + const metadataPath = metadataPathFor(destination); + const suffix = `${process.pid}-${crypto.randomBytes(6).toString('hex')}`; + const binaryTombstone = `${destination}.remove-${suffix}`; + const metadataTombstone = `${metadataPath}.remove-${suffix}`; + let binaryStaged = false; + let metadataStaged = false; + const rename = dependencies.rename || fs.renameSync; + try { + const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination); + if (!status.installed) return { ...plan, dryRun: false, removed: false }; + if (!status.qualified || status.version !== version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.'); + rename(destination, binaryTombstone); + binaryStaged = true; + rename(metadataPath, metadataTombstone); + metadataStaged = true; + const cleanupPending = []; + try { fs.rmSync(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); } + metadataStaged = false; + try { fs.rmSync(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); } + binaryStaged = false; + return { ...plan, dryRun: false, removed: true, cleanupPending }; + } catch (error) { + if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath); + if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination); + throw error; + } finally { releaseLock(); } +} + +module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, acquireLifecycleLock, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory }; diff --git a/scripts/nasiko.js b/scripts/nasiko.js new file mode 100644 index 000000000..27c9c5ddf --- /dev/null +++ b/scripts/nasiko.js @@ -0,0 +1,146 @@ +#!/usr/bin/env node +'use strict'; + +const os = require('os'); +const path = require('path'); +const { + inspectInstalledNasiko, + installNasiko, + normalizePlatform, + uninstallNasiko, + validateInstallDirectory, +} = require('./lib/nasiko-release'); + +function helpText() { + return ` +ECC Nasiko control-plane bridge + +Usage: + ecc nasiko status [--install-dir ] [--json] + ecc nasiko install --version v0.1.0 --yes [--install-dir ] [--json] + ecc nasiko install --version v0.1.0 --dry-run [--install-dir ] [--json] + ecc nasiko uninstall --version v0.1.0 --yes [--install-dir ] [--json] + +The installer is opt-in, accepts only ECC-qualified pinned releases, downloads +content-addressed OCI artifacts from registry.nasiko.dev, verifies SHA-256 +digests before extraction, and never executes fetched shell or PowerShell code. +`; +} + +function parseMutationArguments(argumentsList, command = 'install') { + let options = { dryRun: false, installDir: undefined, json: false, version: undefined, yes: false }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--version' || argument === '--install-dir') { + const value = argumentsList[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`Missing value for ${argument}.`); + options = { + ...options, + [argument === '--version' ? 'version' : 'installDir']: value, + }; + index += 1; + } else if (argument === '--yes' || argument === '-y') { + options = { ...options, yes: true }; + } else if (argument === '--dry-run') { + options = { ...options, dryRun: true }; + } else if (argument === '--json') { + options = { ...options, json: true }; + } else { + throw new Error(`Unknown Nasiko ${command} argument: ${argument}`); + } + } + if (!options.version) throw new Error(`Nasiko ${command} requires --version v0.1.0.`); + if (options.installDir) validateInstallDirectory(options.installDir); + return options; +} + +function defaultExecutablePath() { + const normalized = normalizePlatform(); + if (normalized.os === 'windows') { + return process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, 'nasiko', 'bin', normalized.binaryName) + : null; + } + return path.join(os.homedir(), '.local', 'bin', normalized.binaryName); +} + +function resolveExecutable(options = {}) { + const configured = process.env.ECC_NASIKO_CLI_EXECUTABLE; + const normalized = normalizePlatform(); + const candidate = options.installDir + ? path.join(validateInstallDirectory(options.installDir), normalized.binaryName) + : configured || defaultExecutablePath(); + if (!candidate) return null; + if (!path.isAbsolute(candidate)) { + throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.'); + } + return candidate; +} + +function readStatus(options = {}) { + const executable = resolveExecutable(options); + if (!executable) return { installed: false, qualified: false, version: null, executable: null }; + return inspectInstalledNasiko(executable); +} + +function parseStatusArguments(argumentsList) { + let options = { installDir: undefined, json: false }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--json') options = { ...options, json: true }; + else if (argument === '--install-dir') { + const value = argumentsList[index + 1]; + if (!value || value.startsWith('--')) throw new Error('Missing value for --install-dir.'); + options = { ...options, installDir: validateInstallDirectory(value) }; + index += 1; + } else throw new Error(`Unknown Nasiko status argument: ${argument}`); + } + return options; +} + +async function main(argumentsList = process.argv.slice(2)) { + const [command, ...rest] = argumentsList; + if (!command || command === '--help' || command === '-h' || command === 'help') { + process.stdout.write(helpText()); + return 0; + } + if (command === 'status') { + const options = parseStatusArguments(rest); + const status = readStatus(options); + if (options.json) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + else process.stdout.write(status.qualified + ? `Qualified Nasiko ${status.version} is installed at ${status.executable}.\n` + : status.installed + ? `An unqualified Nasiko file exists at ${status.executable}; it was not executed.\n` + : 'Nasiko is not installed in the ECC-qualified location.\n'); + return 0; + } + if (command === 'install') { + const options = parseMutationArguments(rest, 'install'); + const result = await installNasiko(options); + if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + else if (result.dryRun) process.stdout.write(`Would install Nasiko ${result.version} to ${result.destination}.\n`); + else process.stdout.write(`${result.reused ? 'Using existing' : 'Installed'} Nasiko ${result.version} at ${result.destination}.\n`); + return 0; + } + if (command === 'uninstall') { + const options = parseMutationArguments(rest, 'uninstall'); + const result = uninstallNasiko(options); + if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + else if (result.dryRun) process.stdout.write(`Would uninstall Nasiko ${result.version} from ${result.destination}.\n`); + else process.stdout.write(result.removed ? `Uninstalled Nasiko ${result.version}.\n` : 'Nasiko was not installed.\n'); + return 0; + } + throw new Error(`Unsupported Nasiko command: ${command}`); +} + +if (require.main === module) { + main().then(code => { + process.exitCode = code; + }).catch(error => { + process.stderr.write(`Error: ${String(error?.message || error).replace(/[\r\n]+/g, ' ')}\n`); + process.exitCode = 1; + }); +} + +module.exports = { main, parseInstallArguments: parseMutationArguments, parseMutationArguments, parseStatusArguments, readStatus, resolveExecutable }; diff --git a/skills/nasiko-control-plane/SKILL.md b/skills/nasiko-control-plane/SKILL.md new file mode 100644 index 000000000..bb95391d7 --- /dev/null +++ b/skills/nasiko-control-plane/SKILL.md @@ -0,0 +1,49 @@ +--- +name: nasiko-control-plane +description: Install, detect, and operate the optional Nasiko agent control plane through ECC with pinned artifacts, explicit consent, and telemetry and secrets boundaries. +--- + +# Nasiko Control Plane + +Use this skill when a user explicitly asks to install, inspect, or operate the +Nasiko control plane with ECC. + +## Safety contract + +- Begin with `ecc nasiko status --json`. Status is read-only. +- Installation always requires explicit user consent and `--yes`. +- Install only an ECC-qualified pinned version, currently `v0.1.0`. +- Preview first with `ecc nasiko install --version v0.1.0 --dry-run --json`. +- Install with `ecc nasiko install --version v0.1.0 --yes --json` only after the + user reviews the version, registry origin, digest, and destination. +- Remove only a still-qualified ECC-managed binary with + `ecc nasiko uninstall --version v0.1.0 --yes --json`. Preview removal with + `--dry-run` first. +- The qualified source is `https://github.com/Nasiko-Labs/nasiko`, licensed + under Apache-2.0; artifact and extracted-binary SHA-256 values are pinned. +- Never replace the qualified command with a downloaded shell or PowerShell + bootstrap script. +- Never put secrets or credentials in command arguments, logs, skill output, + install metadata, or ECC state. +- Nasiko telemetry and any sharing with Nasiko or Ito must be opt-in and + separately disclosed. Installation is not telemetry consent. + +## Lifecycle boundary + +The initial ECC bridge supports qualified installation, read-only status, and +ownership-checked uninstall. Use the canonical Nasiko CLI directly for connection, authentication, +launch, deployment, or shutdown until those verbs have their own verified ECC +contracts. Do not guess CLI verbs. + +Installing the CLI does not prove that a control-plane server is running, an +agent is governed, routing or ACLs work, observability is complete, telemetry +was enabled, or Ito compute is connected. Report each state separately. + +## Failure behavior + +- If the platform, architecture, version, manifest, digest, archive, binary, or + destination fails validation, stop without executing the artifact. +- Do not fall back to `latest`. +- Do not search arbitrary `PATH` entries. Use ECC's qualified location or an + explicit absolute `ECC_NASIKO_CLI_EXECUTABLE` for development verification. +- Do not treat a partial or ambiguous installation as success. diff --git a/skills/nasiko-control-plane/agents/openai.yaml b/skills/nasiko-control-plane/agents/openai.yaml new file mode 100644 index 000000000..6168412b7 --- /dev/null +++ b/skills/nasiko-control-plane/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Nasiko Control Plane" + short_description: "Safely install and inspect the optional Nasiko control plane" + default_prompt: "Use $nasiko-control-plane to inspect or explicitly install the pinned Nasiko CLI without enabling telemetry or exposing secrets." diff --git a/tests/ci/nasiko-control-plane.test.js b/tests/ci/nasiko-control-plane.test.js new file mode 100644 index 000000000..8f9d7746f --- /dev/null +++ b/tests/ci/nasiko-control-plane.test.js @@ -0,0 +1,355 @@ +/** + * Contract and lifecycle tests for the opt-in Nasiko control-plane bridge. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); + +function read(relativePath) { + return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8'); +} + +function readJson(relativePath) { + return JSON.parse(read(relativePath)); +} + +async function runTest(name, testFunction) { + try { + await testFunction(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.error(` ${error.message}`); + return false; + } +} + +function sha256Digest(value) { + const crypto = require('crypto'); + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +async function main() { + console.log('\n=== Testing Nasiko control-plane integration ===\n'); + + const tests = [ + ['qualifies only pinned platform releases and rejects latest', () => { + const { + getQualifiedRelease, + normalizePlatform, + } = require('../../scripts/lib/nasiko-release'); + + assert.deepStrictEqual(normalizePlatform('darwin', 'arm64'), { + os: 'darwin', + arch: 'arm64', + binaryName: 'nasiko', + }); + assert.deepStrictEqual(normalizePlatform('win32', 'x64'), { + os: 'windows', + arch: 'amd64', + binaryName: 'nasiko.exe', + }); + assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').manifestDigest, /^sha256:[a-f0-9]{64}$/); + assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').binaryDigest, /^sha256:[a-f0-9]{64}$/); + assert.strictEqual(getQualifiedRelease('v0.1.0', 'linux', 'x64').license, 'Apache-2.0'); + assert.throws(() => getQualifiedRelease('latest', 'darwin', 'arm64'), /pinned version/i); + assert.throws(() => getQualifiedRelease('v1.0.0', 'darwin', 'arm64'), /not qualified/i); + assert.throws(() => normalizePlatform('freebsd', 'x64'), /unsupported platform/i); + assert.throws(() => normalizePlatform('darwin', 'ia32'), /unsupported architecture/i); + }], + ['requires explicit consent while dry-run remains offline and read-only', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + let fetchCount = 0; + const dependencies = { + fetchBytes: async () => { + fetchCount += 1; + throw new Error('dry-run fetched the network'); + }, + platform: 'darwin', + arch: 'arm64', + }; + + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: false }, dependencies), + /explicit --yes/i + ); + const plan = await installNasiko({ version: 'v0.1.0', dryRun: true }, dependencies); + assert.strictEqual(plan.dryRun, true); + assert.strictEqual(plan.version, 'v0.1.0'); + assert.strictEqual(plan.registryOrigin, 'https://registry.nasiko.dev'); + assert.strictEqual(fetchCount, 0); + }], + ['cleans an exclusively created lifecycle lock when initialization fails', () => { + const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-lock-')); + const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock'); + try { + const failingFileSystem = { + ...fs, + writeFileSync: () => { throw new Error('lock metadata unavailable'); }, + }; + assert.throws(() => acquireLifecycleLock(installRoot, failingFileSystem), /metadata unavailable/i); + assert.strictEqual(fs.existsSync(lockPath), false); + const releaseLock = acquireLifecycleLock(installRoot); + assert.strictEqual(fs.existsSync(lockPath), true); + releaseLock(); + assert.strictEqual(fs.existsSync(lockPath), false); + } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } + }], + ['verifies manifest and blob digests before an atomic install', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-')); + const binary = Buffer.from('#!/bin/sh\necho nasiko 0.1.0\n'); + const manifest = Buffer.from(JSON.stringify({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + layers: [{ + mediaType: 'application/gzip', + digest: sha256Digest(Buffer.from('verified archive')), + size: 16, + }], + })); + const archive = Buffer.from('verified archive'); + + try { + const result = await installNasiko({ + version: 'v0.1.0', + yes: true, + installDir: installRoot, + }, { + platform: 'darwin', + arch: 'arm64', + releaseOverride: { + manifestDigest: sha256Digest(manifest), + binaryDigest: sha256Digest(binary), + }, + fetchBytes: async (url) => url.includes('/manifests/') ? manifest : archive, + extractBinary: () => binary, + }); + assert.strictEqual(result.installed, true); + assert.strictEqual(result.version, 'v0.1.0'); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true); + assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true); + const { getQualifiedRelease, inspectInstalledNasiko, uninstallNasiko } = require('../../scripts/lib/nasiko-release'); + const fakeRelease = { + ...getQualifiedRelease('v0.1.0', 'darwin', 'arm64'), + manifestDigest: sha256Digest(manifest), + binaryDigest: sha256Digest(binary), + }; + const preview = await uninstallNasiko({ installDir: installRoot, dryRun: true }, { + platform: 'darwin', arch: 'arm64', + }); + assert.strictEqual(preview.dryRun, true); + assert.strictEqual(preview.version, 'v0.1.0'); + assert.strictEqual(preview.destination, path.join(fs.realpathSync(installRoot), 'nasiko')); + let renameCount = 0; + await assert.rejects(async () => uninstallNasiko({ installDir: installRoot, yes: true }, { + platform: 'darwin', arch: 'arm64', + inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease), + rename: (source, destination) => { + renameCount += 1; + if (renameCount === 2) throw new Error('metadata staging unavailable'); + fs.renameSync(source, destination); + }, + }), /metadata staging unavailable/i); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true); + assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true); + await uninstallNasiko({ installDir: installRoot, yes: true }, { + platform: 'darwin', arch: 'arm64', + inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease), + }); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); + assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), false); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['rejects digest mismatch and unsafe archive entries without installing', async () => { + const { extractQualifiedTarGzip, installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-reject-')); + const manifest = Buffer.from('{"schemaVersion":2,"layers":[]}'); + try { + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', + arch: 'arm64', + releaseOverride: { manifestDigest: `sha256:${'0'.repeat(64)}` }, + fetchBytes: async () => manifest, + }), + /manifest digest mismatch/i + ); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); + assert.throws(() => extractQualifiedTarGzip(Buffer.from('not gzip'), 'nasiko'), /invalid|size limit/i); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['read-only status never executes an unqualified explicit executable', () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-status-')); + const executable = path.join(fixtureRoot, 'nasiko'); + const marker = path.join(fixtureRoot, 'executed'); + fs.writeFileSync(executable, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nprintf "nasiko 0.1.0\\n"\n`, { mode: 0o755 }); + try { + const result = spawnSync(process.execPath, [ + path.join(REPO_ROOT, 'scripts', 'ecc.js'), + 'nasiko', + 'status', + '--json', + ], { + encoding: 'utf8', + env: { ...process.env, ECC_NASIKO_CLI_EXECUTABLE: executable }, + }); + assert.strictEqual(result.status, 0, result.stderr); + const status = JSON.parse(result.stdout); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.qualified, false); + assert.strictEqual(status.version, null); + assert.strictEqual(status.executable, executable); + assert.strictEqual(fs.existsSync(marker), false); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }], + ['read-only status has a stable absent result shape', () => { + const { readStatus } = require('../../scripts/nasiko'); + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-absent-')); + try { + assert.deepStrictEqual(readStatus({ installDir: fixtureRoot }), { + installed: false, + qualified: false, + version: null, + executable: path.join(fs.realpathSync(fixtureRoot), 'nasiko'), + }); + } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } + }], + ['rejects and never executes an unqualified pre-existing binary', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-existing-')); + const executable = path.join(installRoot, 'nasiko'); + const marker = path.join(installRoot, 'executed'); + fs.writeFileSync(executable, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\necho nasiko 0.1.0\n`, { mode: 0o755 }); + try { + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', arch: 'arm64', + }), + /unqualified|digest|metadata/i + ); + assert.strictEqual(fs.existsSync(marker), false); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['rolls back a published binary when metadata persistence fails', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-rollback-')); + const binary = Buffer.from('qualified binary'); + const archive = Buffer.from('verified archive'); + const manifest = Buffer.from(JSON.stringify({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + layers: [{ mediaType: 'application/gzip', digest: sha256Digest(archive), size: archive.length }], + })); + try { + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', + arch: 'arm64', + releaseOverride: { + manifestDigest: sha256Digest(manifest), + binaryDigest: sha256Digest(binary), + }, + fetchBytes: async url => url.includes('/manifests/') ? manifest : archive, + extractBinary: () => binary, + writeMetadata: () => { throw new Error('metadata unavailable'); }, + }), + /metadata unavailable/i + ); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['never overwrites or deletes a destination created during publication', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-race-')); + const binary = Buffer.from('qualified binary'); + const intruder = Buffer.from('concurrent owner'); + const archive = Buffer.from('verified archive'); + const manifest = Buffer.from(JSON.stringify({ schemaVersion: 2, layers: [{ mediaType: 'application/gzip', digest: sha256Digest(archive), size: archive.length }] })); + try { + await assert.rejects(installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', arch: 'arm64', + releaseOverride: { manifestDigest: sha256Digest(manifest), binaryDigest: sha256Digest(binary) }, + fetchBytes: async url => url.includes('/manifests/') ? manifest : archive, + extractBinary: () => binary, + beforePublish: destination => fs.writeFileSync(destination, intruder, { flag: 'wx' }), + }), /exist/i); + assert.deepStrictEqual(fs.readFileSync(path.join(installRoot, 'nasiko')), intruder); + } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } + }], + ['ships a canonical opt-in skill without silently bundling Nasiko', () => { + const skill = read('skills/nasiko-control-plane/SKILL.md'); + assert.match(skill, /^name: nasiko-control-plane$/m); + assert.match(skill, /ecc nasiko status/i); + assert.match(skill, /explicit.*consent|explicit.*--yes/i); + assert.match(skill, /pinned.*v0\.1\.0/i); + assert.match(skill, /telemetry.*opt-in/i); + assert.match(skill, /never.*secrets|never.*credentials/i); + assert.match(skill, /install.*does not prove/i); + assert.match(skill, /ecc nasiko uninstall/i); + assert.doesNotMatch(skill, /curl[^\n]*\|[^\n]*bash|irm[^\n]*\|[^\n]*iex/i); + + const modules = readJson('manifests/install-modules.json').modules; + const module = modules.find(candidate => candidate.id === 'nasiko-control-plane'); + assert.ok(module, 'nasiko-control-plane module is missing'); + assert.deepStrictEqual(module.paths, ['skills/nasiko-control-plane']); + assert.deepStrictEqual(module.dependencies, ['platform-configs']); + assert.strictEqual(module.defaultInstall, false); + assert.strictEqual(module.stability, 'experimental'); + + const components = readJson('manifests/install-components.json').components; + assert.deepStrictEqual( + components.find(candidate => candidate.id === 'capability:nasiko-control-plane'), + { + id: 'capability:nasiko-control-plane', + family: 'capability', + description: 'Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.', + modules: ['nasiko-control-plane'], + } + ); + + const profiles = readJson('manifests/install-profiles.json').profiles; + assert.ok(profiles.full.modules.includes('nasiko-control-plane')); + for (const [profileId, profile] of Object.entries(profiles)) { + if (profileId !== 'full') assert.ok(!profile.modules.includes('nasiko-control-plane')); + } + + const packageJson = readJson('package.json'); + assert.ok(packageJson.files.includes('skills/nasiko-control-plane/')); + assert.ok(packageJson.files.includes('scripts/nasiko.js')); + assert.ok(packageJson.files.includes('scripts/lib/')); + assert.ok(!packageJson.dependencies?.nasiko); + assert.ok(!packageJson.optionalDependencies?.nasiko); + }], + ]; + + let passed = 0; + let failed = 0; + for (const [name, testFunction] of tests) { + if (await runTest(name, testFunction)) passed += 1; + else failed += 1; + } + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +main(); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 3ca4662cc..4f6a8d48d 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -62,6 +62,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/loop-status.js", "scripts/memory.js", "scripts/memory-mcp.mjs", + "scripts/nasiko.js", "scripts/observability-readiness.js", "scripts/plan-canvas.js", "scripts/operator-readiness-dashboard.js", @@ -160,6 +161,8 @@ function main() { "scripts/ito.js", "scripts/memory.js", "scripts/memory-mcp.mjs", + "scripts/nasiko.js", + "scripts/lib/nasiko-release.js", "scripts/lib/memory-vault-format.js", "scripts/lib/memory-vault.js", "scripts/discussion-audit.js",