From 1159fac64045a0ae67e8a3cf0d5271b10c7b5136 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 18:44:13 -0800 Subject: [PATCH] feat(skills): add confirmed no-op execution receipts --- docs/plans/state.json | 13 ++++- src/cli/skills.test.ts | 81 +++++++++++++++++++++++++++++-- src/cli/skills.ts | 108 ++++++++++++++++++++++++++++++----------- 3 files changed, 168 insertions(+), 34 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 47c3231..e89afac 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1406,6 +1406,15 @@ "src/cli/skills.test.ts" ], "test_status": "pnpm typecheck + pnpm test:run src/cli/skills.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" + }, + "installer_safety_confirmation_receipts": { + "status": "completed", + "description": "Added explicit --confirm semantics and stable no-op execution receipt fields for plan-only/stub/install JSON outputs while keeping installer command execution disabled", + "files_modified": [ + "src/cli/skills.ts", + "src/cli/skills.test.ts" + ], + "test_status": "pnpm typecheck + pnpm test:run src/cli/skills.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing" } } } @@ -1434,7 +1443,7 @@ }, "overall_progress": { - "total_test_count": 1533, + "total_test_count": 1535, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1454,7 +1463,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Skills infrastructure Phase 3: add explicit safety confirmations and no-op execution receipts for future real installer execution path" + "next_up": "Skills infrastructure Phase 3: add explicit execution-policy gate checks for future real installer command runners while preserving no-op default" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 5ac8a30..28cb1bc 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -230,6 +230,11 @@ describe('skills CLI helpers', () => { ); expect(view.execution).toBe('stub'); + expect(view.mode).toBe('stub'); + expect(view.confirmed).toBe(false); + expect(view.execution_enabled).toBe(false); + expect(view.executed).toEqual([]); + expect(view.reason).toBe('execution_disabled'); expect(view.wouldRun.length).toBe(1); expect(view.wouldRun[0]).toContain('download https://example.com/tool.tgz'); }); @@ -238,6 +243,11 @@ describe('skills CLI helpers', () => { const output = renderSkillInstallerExecutionStub({ skill: { name: 'exec-stub', tier: 'bundled', version: '1.0.0' }, execution: 'stub', + mode: 'stub', + confirmed: false, + execution_enabled: false, + executed: [], + reason: 'execution_disabled', wouldRun: ['brew install jq'], skipped: [{ installerType: 'node', reason: 'neither pnpm nor npm available in PATH' }], }); @@ -258,9 +268,14 @@ describe('skills CLI helpers', () => { skipped: [], }; - const view = toSkillInstallerExecutionStubFromPreflight(preflight); + const view = toSkillInstallerExecutionStubFromPreflight(preflight, { mode: 'install', confirmed: true }); expect(view.execution).toBe('stub'); + expect(view.mode).toBe('install'); + expect(view.confirmed).toBe(true); + expect(view.execution_enabled).toBe(false); + expect(view.executed).toEqual([]); + expect(view.reason).toBe('execution_disabled'); expect(view.wouldRun).toEqual(['download https://example.com/a.tgz -> /tmp/a.tgz']); }); @@ -336,7 +351,7 @@ describe('skills CLI helpers', () => { ); const installer = new SkillInstaller(managedDir); - const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: false }); + const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: false, confirmed: false }); expect(result.ok).toBe(true); expect(existsSync(join(managedDir, 'plan-skill', 'SKILL.md'))).toBe(false); @@ -344,6 +359,66 @@ describe('skills CLI helpers', () => { rmSync(root, { recursive: true, force: true }); }); + it('emits plan-only JSON with no-op execution receipt fields', () => { + const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-')); + const sourceDir = join(root, 'source-skill'); + const managedDir = join(root, 'managed'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(join(sourceDir, 'SKILL.md'), '# Plan Skill\nInstructions'); + writeFileSync( + join(sourceDir, 'manifest.json'), + JSON.stringify({ name: 'plan-skill', description: 'Plan only', version: '1.0.0' }), + 'utf-8', + ); + + const installer = new SkillInstaller(managedDir); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const result = runSkillInstallAction(installer, sourceDir, { mode: 'plan-only', asJson: true, confirmed: true }); + + expect(result.ok).toBe(true); + expect(logSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])); + expect(payload.execution.confirmed).toBe(true); + expect(payload.execution.mode).toBe('plan-only'); + expect(payload.execution.execution_enabled).toBe(false); + expect(payload.execution.executed).toEqual([]); + expect(payload.execution.reason).toBe('execution_disabled'); + + logSpy.mockRestore(); + rmSync(root, { recursive: true, force: true }); + }); + + it('emits install JSON with no-op execution receipt fields', () => { + const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-')); + const sourceDir = join(root, 'source-skill'); + const managedDir = join(root, 'managed'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(join(sourceDir, 'SKILL.md'), '# Install Skill\nInstructions'); + writeFileSync( + join(sourceDir, 'manifest.json'), + JSON.stringify({ name: 'install-skill', description: 'Install', version: '1.0.0' }), + 'utf-8', + ); + + const installer = new SkillInstaller(managedDir); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const result = runSkillInstallAction(installer, sourceDir, { mode: 'install', asJson: true, confirmed: false }); + + expect(result.ok).toBe(true); + expect(logSpy.mock.calls.length).toBeGreaterThan(0); + const payload = JSON.parse(String(logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0])); + expect(payload.execution.confirmed).toBe(false); + expect(payload.execution.mode).toBe('install'); + expect(payload.execution.execution_enabled).toBe(false); + expect(payload.execution.executed).toEqual([]); + expect(payload.execution.reason).toBe('execution_disabled'); + + logSpy.mockRestore(); + rmSync(root, { recursive: true, force: true }); + }); + it('requires --yes confirmation for uninstall helper', () => { const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-')); const installer = new SkillInstaller(join(root, 'managed')); diff --git a/src/cli/skills.ts b/src/cli/skills.ts index efb3ba6..27935c8 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -41,6 +41,11 @@ export interface SkillInstallPreflightView { export interface SkillInstallerExecutionStubView { skill: SkillInstallerPlanView['skill']; execution: 'stub'; + mode: SkillInstallActionMode; + confirmed: boolean; + execution_enabled: boolean; + executed: string[]; + reason: 'execution_disabled'; wouldRun: string[]; skipped: SkillInstallerPlanView['skipped']; } @@ -210,6 +215,11 @@ export function toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerE return { skill: plan.skill, execution: 'stub', + mode: 'stub', + confirmed: false, + execution_enabled: false, + executed: [], + reason: 'execution_disabled', wouldRun: plan.steps.map((step) => step.command), skipped: plan.skipped, }; @@ -217,10 +227,18 @@ export function toSkillInstallerExecutionStubView(skill: Skill): SkillInstallerE export function toSkillInstallerExecutionStubFromPreflight( preflight: SkillInstallPreflightView, + options?: { mode?: SkillInstallActionMode; confirmed?: boolean }, ): SkillInstallerExecutionStubView { + const mode = options?.mode ?? 'stub'; + const confirmed = options?.confirmed ?? false; return { skill: preflight.skill, execution: 'stub', + mode, + confirmed, + execution_enabled: false, + executed: [], + reason: 'execution_disabled', wouldRun: preflight.steps.map((step) => step.command), skipped: preflight.skipped, }; @@ -314,7 +332,7 @@ export function installSkillFromDirectory(installer: SkillInstaller, sourcePath: export function runSkillInstallAction( installer: SkillInstaller, sourcePath: string, - opts: { mode: SkillInstallActionMode; asJson: boolean }, + opts: { mode: SkillInstallActionMode; asJson: boolean; confirmed: boolean }, ): { ok: true } | { ok: false; error: string } { const preflight = toSkillInstallPreflightView(sourcePath); @@ -323,7 +341,11 @@ export function runSkillInstallAction( return { ok: false, error: `Failed to generate install preflight from '${resolve(sourcePath)}'.` }; } if (opts.asJson) { - console.log(JSON.stringify({ preflight }, null, 2)); + const execution = toSkillInstallerExecutionStubFromPreflight(preflight, { + mode: 'plan-only', + confirmed: opts.confirmed, + }); + console.log(JSON.stringify({ preflight, execution }, null, 2)); } else { console.log(renderSkillInstallPreflight(preflight)); } @@ -334,7 +356,10 @@ export function runSkillInstallAction( if (!preflight) { return { ok: false, error: `Failed to generate installer execution stub from '${resolve(sourcePath)}'.` }; } - const stub = toSkillInstallerExecutionStubFromPreflight(preflight); + const stub = toSkillInstallerExecutionStubFromPreflight(preflight, { + mode: 'stub', + confirmed: opts.confirmed, + }); if (opts.asJson) { console.log(JSON.stringify({ execution: stub }, null, 2)); } else { @@ -343,12 +368,8 @@ export function runSkillInstallAction( return { ok: true }; } - if (preflight) { - if (opts.asJson) { - console.log(JSON.stringify({ preflight }, null, 2)); - } else { - console.log(renderSkillInstallPreflight(preflight)); - } + if (preflight && !opts.asJson) { + console.log(renderSkillInstallPreflight(preflight)); } const result = installSkillFromDirectory(installer, sourcePath); @@ -356,10 +377,34 @@ export function runSkillInstallAction( return { ok: false, error: result.error ?? `Failed to install skill from '${sourcePath}'.` }; } + const execution = + preflight !== null + ? toSkillInstallerExecutionStubFromPreflight(preflight, { + mode: 'install', + confirmed: opts.confirmed, + }) + : { + skill: { + name: result.skill.manifest.name, + version: result.skill.manifest.version, + tier: result.skill.manifest.tier, + }, + execution: 'stub' as const, + mode: 'install' as const, + confirmed: opts.confirmed, + execution_enabled: false, + executed: [], + reason: 'execution_disabled' as const, + wouldRun: [], + skipped: [], + }; + if (opts.asJson) { console.log( JSON.stringify( { + preflight, + execution, installed: { name: result.skill.manifest.name, version: result.skill.manifest.version, @@ -464,30 +509,33 @@ export function registerSkillsCommand(program: Command): void { .option('--json', 'Output preflight and install result as JSON') .option('--preflight-only', 'Show installer preflight without performing install') .option('--stub', 'Show installer execution stub without performing install') + .option('--confirm', 'Mark installer execution intent as confirmed (execution remains disabled)') .option('-c, --config ', 'Config file path') - .action((pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; stub?: boolean; config?: string }) => { - const loaded = loadConfigSafe(opts.config); - if (loaded.error || !loaded.config) { - console.error(loaded.error ?? 'Failed to load config'); - process.exitCode = 1; - return; - } + .action( + (pathArg: string, opts: { json?: boolean; preflightOnly?: boolean; stub?: boolean; confirm?: boolean; config?: string }) => { + const loaded = loadConfigSafe(opts.config); + if (loaded.error || !loaded.config) { + console.error(loaded.error ?? 'Failed to load config'); + process.exitCode = 1; + return; + } - const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); - const installer = new SkillInstaller(loaded.config.skills.managed_dir ?? defaultManagedDir); + const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); + const installer = new SkillInstaller(loaded.config.skills.managed_dir ?? defaultManagedDir); - const mode: SkillInstallActionMode = opts.preflightOnly ? 'plan-only' : opts.stub ? 'stub' : 'install'; - const result = runSkillInstallAction(installer, pathArg, { - mode, - asJson: opts.json ?? false, + const mode: SkillInstallActionMode = opts.preflightOnly ? 'plan-only' : opts.stub ? 'stub' : 'install'; + const result = runSkillInstallAction(installer, pathArg, { + mode, + asJson: opts.json ?? false, + confirmed: opts.confirm ?? false, + }); + + if (!result.ok) { + console.error(result.error); + process.exitCode = 1; + } }); - if (!result.ok) { - console.error(result.error); - process.exitCode = 1; - } - }); - skills .command('uninstall ') .description('Uninstall a managed skill by name') @@ -581,8 +629,9 @@ export function registerSkillsCommand(program: Command): void { .command('execute ') .description('Preview installer execution steps (stub only; no commands run)') .option('--json', 'Output as JSON') + .option('--confirm', 'Mark installer execution intent as confirmed (execution remains disabled)') .option('-c, --config ', 'Config file path') - .action((name: string, opts: { json?: boolean; config?: string }) => { + .action((name: string, opts: { json?: boolean; confirm?: boolean; config?: string }) => { const loaded = loadSkillsFromConfig(opts.config); if (loaded.error || !loaded.skills) { console.error(loaded.error ?? 'Failed to load skills'); @@ -598,6 +647,7 @@ export function registerSkillsCommand(program: Command): void { } const view = toSkillInstallerExecutionStubView(skill); + view.confirmed = opts.confirm ?? false; if (opts.json) { console.log(JSON.stringify(view, null, 2)); return;