From 91dfb1b46bfb89d5b47a312f9b1bf45da8350305 Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 16:17:01 +0000 Subject: [PATCH 1/4] Add SKILL.md init generation (#95) --- .gitignore | 1 - docs/SKILL.md | 100 ++++++++++++++++++++ src/specleft/commands/init.py | 15 +++ src/specleft/templates/skill_template.py | 114 +++++++++++++++++++++++ tests/commands/test_init.py | 28 ++++++ 5 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 docs/SKILL.md create mode 100644 src/specleft/templates/skill_template.py diff --git a/.gitignore b/.gitignore index 4614a48..8ccc1c9 100644 --- a/.gitignore +++ b/.gitignore @@ -232,5 +232,4 @@ prd.md .specleft/specs/overview.md .licenses/policy.yml bandit-report.json -docs/SKILL.md PLAN.md diff --git a/docs/SKILL.md b/docs/SKILL.md new file mode 100644 index 0000000..1fd50da --- /dev/null +++ b/docs/SKILL.md @@ -0,0 +1,100 @@ +# SpecLeft CLI Reference + +## Workflow +1. specleft next --format json +2. Implement test logic +3. specleft features validate --format json +4. pytest +5. Repeat + +## Safety +- Always `--dry-run` before writing files +- Never use `--force` unless explicitly requested +- Exit codes: 0 = success, 1 = error, 2 = cancelled +- Commands are deterministic and safe to retry + +--- + +## Features + +### Validate specs +`specleft features validate --format json [--dir PATH] [--strict]` +Validate before generating tests. `--strict` treats warnings as errors. + +### List features +`specleft features list --format json [--dir PATH]` + +### Show stats +`specleft features stats --format json [--dir PATH] [--tests-dir PATH]` + +### Add a feature +`specleft features add --format json --id FEATURE_ID --title "Title" [--priority critical|high|medium|low] [--description TEXT] [--dir PATH] [--dry-run]` +Creates `/feature-id.md`. Never overwrites existing files. +Use `--interactive` for guided prompts (TTY only). + +### Add a scenario +`specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority critical|high|medium|low] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test stub|skeleton] [--preview-test]` +Appends to feature file. `--add-test` generates a test file. +`--preview-test` shows test content without writing. Use `--interactive` +for guided prompts (TTY only). + +## Status and Planning + +### Show status +`specleft status --format json [--dir PATH] [--feature ID] [--story ID] [--unimplemented] [--implemented]` + +### Next scenario to implement +`specleft next --format json [--dir PATH] [--limit N] [--priority critical|high|medium|low] [--feature ID] [--story ID]` + +### Coverage metrics +`specleft coverage --format json [--dir PATH] [--threshold N] [--output PATH]` +`--threshold N` exits non-zero if coverage drops below `N%`. + +## Test Generation + +### Generate skeleton tests +`specleft test skeleton --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` +Always run `--dry-run` first. Never overwrite without `--force`. + +### Generate stub tests +`specleft test stub --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` +Minimal test scaffolding with the same overwrite safety rules. + +### Generate test report +`specleft test report --format json [-r RESULTS_FILE] [-o OUTPUT_PATH] [--open-browser]` +Builds an HTML report from `.specleft/results/`. + +## Planning + +### Generate specs from PRD +`specleft plan --format json [--from PATH] [--dry-run] [--analyze] [--template PATH]` +`--analyze` inspects PRD structure without writing files. +`--template` uses a YAML section-matching template. + +## Contract + +### Show contract +`specleft contract --format json` + +### Verify contract +`specleft contract test --format json [--verbose]` +Run to verify deterministic/safe command guarantees. + +## Enforcement + +### Enforce policy +`specleft enforce [POLICY_FILE] --format json [--dir PATH] [--tests PATH] [--ignore-feature-id ID]` +Default policy: `.specleft/policies/policy.yml`. +Exit codes: 0 = satisfied, 1 = violated, 2 = license issue. + +## License + +### License status +`specleft license status [--file PATH]` +Show license status and validated policy metadata. +Default: `.specleft/policies/policy.yml`. + +## Guide + +### Show workflow guide +`specleft guide --format json` diff --git a/src/specleft/commands/init.py b/src/specleft/commands/init.py index edb023d..b38117b 100644 --- a/src/specleft/commands/init.py +++ b/src/specleft/commands/init.py @@ -13,6 +13,7 @@ import click +from specleft.templates.skill_template import get_skill_content from specleft.utils.messaging import print_support_footer _PRD_TEMPLATE_CONTENT = """\ @@ -110,6 +111,7 @@ def _init_plan(example: bool) -> tuple[list[Path], list[tuple[Path, str]]]: if example: for rel_path, content in _init_example_content().items(): files.append((Path(rel_path), content)) + files.append((Path(".specleft/SKILL.md"), get_skill_content())) files.append((Path(".specleft/.gitkeep"), "")) files.append((Path(".specleft/policies/.gitkeep"), "")) files.append((Path(".specleft/templates/prd-template.yml"), _PRD_TEMPLATE_CONTENT)) @@ -201,6 +203,19 @@ def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: if blank: example = False + skill_file = Path(".specleft/SKILL.md") + if skill_file.exists(): + warning = "Skipped creation. Specleft SKILL.md exists already." + if format_type == "json": + payload_cancelled = { + "status": "cancelled", + "message": warning, + } + click.echo(json.dumps(payload_cancelled, indent=2)) + else: + click.secho(f"Warning: {warning}", fg="yellow") + sys.exit(2) + features_dir = Path(".specleft/specs") if features_dir.exists(): if format_type == "json": diff --git a/src/specleft/templates/skill_template.py b/src/specleft/templates/skill_template.py new file mode 100644 index 0000000..4b37f42 --- /dev/null +++ b/src/specleft/templates/skill_template.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Default SpecLeft skill reference content.""" + +from __future__ import annotations + +import textwrap + + +def get_skill_content() -> str: + """Return canonical SKILL.md content created by ``specleft init``.""" + return textwrap.dedent(""" + # SpecLeft CLI Reference + + ## Workflow + 1. specleft next --format json + 2. Implement test logic + 3. specleft features validate --format json + 4. pytest + 5. Repeat + + ## Safety + - Always `--dry-run` before writing files + - Never use `--force` unless explicitly requested + - Exit codes: 0 = success, 1 = error, 2 = cancelled + - Commands are deterministic and safe to retry + + --- + + ## Features + + ### Validate specs + `specleft features validate --format json [--dir PATH] [--strict]` + Validate before generating tests. `--strict` treats warnings as errors. + + ### List features + `specleft features list --format json [--dir PATH]` + + ### Show stats + `specleft features stats --format json [--dir PATH] [--tests-dir PATH]` + + ### Add a feature + `specleft features add --format json --id FEATURE_ID --title "Title" [--priority critical|high|medium|low] [--description TEXT] [--dir PATH] [--dry-run]` + Creates `/feature-id.md`. Never overwrites existing files. + Use `--interactive` for guided prompts (TTY only). + + ### Add a scenario + `specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority critical|high|medium|low] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test stub|skeleton] [--preview-test]` + Appends to feature file. `--add-test` generates a test file. + `--preview-test` shows test content without writing. Use `--interactive` + for guided prompts (TTY only). + + ## Status and Planning + + ### Show status + `specleft status --format json [--dir PATH] [--feature ID] [--story ID] [--unimplemented] [--implemented]` + + ### Next scenario to implement + `specleft next --format json [--dir PATH] [--limit N] [--priority critical|high|medium|low] [--feature ID] [--story ID]` + + ### Coverage metrics + `specleft coverage --format json [--dir PATH] [--threshold N] [--output PATH]` + `--threshold N` exits non-zero if coverage drops below `N%`. + + ## Test Generation + + ### Generate skeleton tests + `specleft test skeleton --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` + Always run `--dry-run` first. Never overwrite without `--force`. + + ### Generate stub tests + `specleft test stub --format json [-f FEATURES_DIR] [-o OUTPUT_DIR] [--dry-run] [--force] [--single-file] [--skip-preview]` + Minimal test scaffolding with the same overwrite safety rules. + + ### Generate test report + `specleft test report --format json [-r RESULTS_FILE] [-o OUTPUT_PATH] [--open-browser]` + Builds an HTML report from `.specleft/results/`. + + ## Planning + + ### Generate specs from PRD + `specleft plan --format json [--from PATH] [--dry-run] [--analyze] [--template PATH]` + `--analyze` inspects PRD structure without writing files. + `--template` uses a YAML section-matching template. + + ## Contract + + ### Show contract + `specleft contract --format json` + + ### Verify contract + `specleft contract test --format json [--verbose]` + Run to verify deterministic/safe command guarantees. + + ## Enforcement + + ### Enforce policy + `specleft enforce [POLICY_FILE] --format json [--dir PATH] [--tests PATH] [--ignore-feature-id ID]` + Default policy: `.specleft/policies/policy.yml`. + Exit codes: 0 = satisfied, 1 = violated, 2 = license issue. + + ## License + + ### License status + `specleft license status [--file PATH]` + Show license status and validated policy metadata. + Default: `.specleft/policies/policy.yml`. + + ## Guide + + ### Show workflow guide + `specleft guide --format json` + """).strip() + "\n" diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py index 0a559c2..c90c308 100644 --- a/tests/commands/test_init.py +++ b/tests/commands/test_init.py @@ -17,6 +17,7 @@ def test_init_creates_single_file_example(self) -> None: with runner.isolated_filesystem(): result = runner.invoke(cli, ["init"], input="1\n") assert result.exit_code == 0 + assert Path(".specleft/SKILL.md").exists() assert Path(".specleft/specs/example-feature.md").exists() assert Path(".specleft/templates/prd-template.yml").exists() @@ -28,6 +29,7 @@ def test_init_json_dry_run(self) -> None: payload = json.loads(result.output) assert payload["dry_run"] is True assert payload["summary"]["directories"] == 5 + assert ".specleft/SKILL.md" in payload["would_create"] assert ".specleft/specs/example-feature.md" in payload["would_create"] assert ".specleft/templates/prd-template.yml" in payload["would_create"] @@ -84,3 +86,29 @@ def test_init_existing_features_cancel(self) -> None: result = runner.invoke(cli, ["init"], input="3\n") assert result.exit_code == 2 assert "Cancelled" in result.output + + def test_init_existing_skill_file_warns_and_exits(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path(".specleft").mkdir(parents=True) + Path(".specleft/SKILL.md").write_text("# existing\n") + result = runner.invoke(cli, ["init"]) + assert result.exit_code == 2 + assert ( + "Warning: Skipped creation. Specleft SKILL.md exists already." + in result.output + ) + + def test_init_existing_skill_file_json_cancelled(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path(".specleft").mkdir(parents=True) + Path(".specleft/SKILL.md").write_text("# existing\n") + result = runner.invoke(cli, ["init", "--format", "json"]) + assert result.exit_code == 2 + payload = json.loads(result.output) + assert payload["status"] == "cancelled" + assert ( + payload["message"] + == "Skipped creation. Specleft SKILL.md exists already." + ) From d3ad32906565446d25bf08ec766bc49b1ff7752a Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 17:04:04 +0000 Subject: [PATCH 2/4] Add SKILL integrity tooling and commands (#95) --- docs/SKILL.md | 27 +- src/specleft/cli/main.py | 2 + src/specleft/commands/__init__.py | 2 + src/specleft/commands/contracts/payloads.py | 4 + src/specleft/commands/doctor.py | 66 ++++- src/specleft/commands/init.py | 46 +-- src/specleft/commands/skill.py | 100 +++++++ src/specleft/templates/skill_template.py | 27 +- src/specleft/utils/skill_integrity.py | 299 ++++++++++++++++++++ tests/cli/test_cli_base.py | 1 + tests/commands/test_contract.py | 3 + tests/commands/test_doctor.py | 31 ++ tests/commands/test_init.py | 34 +-- tests/commands/test_skill.py | 94 ++++++ 14 files changed, 674 insertions(+), 62 deletions(-) create mode 100644 src/specleft/commands/skill.py create mode 100644 src/specleft/utils/skill_integrity.py create mode 100644 tests/commands/test_skill.py diff --git a/docs/SKILL.md b/docs/SKILL.md index 1fd50da..d888107 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -4,8 +4,9 @@ 1. specleft next --format json 2. Implement test logic 3. specleft features validate --format json -4. pytest -5. Repeat +4. specleft skill verify --format json +5. pytest +6. Repeat ## Safety - Always `--dry-run` before writing files @@ -28,12 +29,12 @@ Validate before generating tests. `--strict` treats warnings as errors. `specleft features stats --format json [--dir PATH] [--tests-dir PATH]` ### Add a feature -`specleft features add --format json --id FEATURE_ID --title "Title" [--priority critical|high|medium|low] [--description TEXT] [--dir PATH] [--dry-run]` +`specleft features add --format json --id FEATURE_ID --title "Title" [--priority PRIORITY] [--description TEXT] [--dir PATH] [--dry-run]` Creates `/feature-id.md`. Never overwrites existing files. Use `--interactive` for guided prompts (TTY only). ### Add a scenario -`specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority critical|high|medium|low] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test stub|skeleton] [--preview-test]` +`specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority PRIORITY] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test MODE] [--preview-test]` Appends to feature file. `--add-test` generates a test file. `--preview-test` shows test content without writing. Use `--interactive` for guided prompts (TTY only). @@ -44,7 +45,7 @@ for guided prompts (TTY only). `specleft status --format json [--dir PATH] [--feature ID] [--story ID] [--unimplemented] [--implemented]` ### Next scenario to implement -`specleft next --format json [--dir PATH] [--limit N] [--priority critical|high|medium|low] [--feature ID] [--story ID]` +`specleft next --format json [--dir PATH] [--limit N] [--priority PRIORITY] [--feature ID] [--story ID]` ### Coverage metrics `specleft coverage --format json [--dir PATH] [--threshold N] [--output PATH]` @@ -78,7 +79,21 @@ Builds an HTML report from `.specleft/results/`. ### Verify contract `specleft contract test --format json [--verbose]` -Run to verify deterministic/safe command guarantees. +Run to verify deterministic and safe command guarantees. + +## Skill Security + +### Verify skill integrity +`specleft skill verify --format json` +Returns `pass`, `modified`, or `outdated` integrity status. + +### Update skill files +`specleft skill update --format json` +Regenerates `.specleft/SKILL.md` and `.specleft/SKILL.md.sha256`. + +### Verify within doctor checks +`specleft doctor --verify-skill --format json` +Adds skill integrity status to standard environment diagnostics. ## Enforcement diff --git a/src/specleft/cli/main.py b/src/specleft/cli/main.py index 4f731e8..24f174b 100644 --- a/src/specleft/cli/main.py +++ b/src/specleft/cli/main.py @@ -18,6 +18,7 @@ license_group, next_command, plan, + skill_group, status, test, ) @@ -50,6 +51,7 @@ def cli() -> None: cli.add_command(contract) cli.add_command(enforce) cli.add_command(license_group) +cli.add_command(skill_group) cli.add_command(guide) diff --git a/src/specleft/commands/__init__.py b/src/specleft/commands/__init__.py index fafbfbe..370363c 100644 --- a/src/specleft/commands/__init__.py +++ b/src/specleft/commands/__init__.py @@ -15,6 +15,7 @@ from specleft.commands.license import license_group from specleft.commands.next import next_command from specleft.commands.plan import plan +from specleft.commands.skill import skill_group from specleft.commands.status import status from specleft.commands.test import test @@ -29,6 +30,7 @@ "license_group", "next_command", "plan", + "skill_group", "status", "test", ] diff --git a/src/specleft/commands/contracts/payloads.py b/src/specleft/commands/contracts/payloads.py index efe0390..4588a37 100644 --- a/src/specleft/commands/contracts/payloads.py +++ b/src/specleft/commands/contracts/payloads.py @@ -37,6 +37,10 @@ def build_contract_payload() -> dict[str, object]: "cancelled": 2, }, }, + "skill_security": { + "skill_file_integrity_check": True, + "skill_file_commands_are_simple": True, + }, }, "docs": { "agent_contract": CONTRACT_DOC_PATH, diff --git a/src/specleft/commands/doctor.py b/src/specleft/commands/doctor.py index 82dbd25..742ccb0 100644 --- a/src/specleft/commands/doctor.py +++ b/src/specleft/commands/doctor.py @@ -18,6 +18,11 @@ from specleft.commands.constants import CLI_VERSION from specleft.utils.messaging import print_support_footer +from specleft.utils.skill_integrity import ( + INTEGRITY_MODIFIED, + INTEGRITY_OUTDATED, + verify_skill_integrity, +) from specleft.utils.specs_dir import resolve_specs_dir @@ -49,7 +54,7 @@ def _load_dependency_names() -> list[str]: return parsed or dependencies -def _build_doctor_checks() -> dict[str, Any]: +def _build_doctor_checks(*, verify_skill: bool) -> dict[str, Any]: import importlib.metadata as metadata cli_check = {"status": "pass", "version": CLI_VERSION} @@ -137,15 +142,28 @@ def _build_doctor_checks() -> dict[str, Any]: "tests_writable": tests_writable, } + checks: dict[str, Any] = { + "cli_available": cli_check, + "pytest_plugin": plugin_check, + "python_version": python_check, + "dependencies": dependency_check, + "directories": directory_check, + } + if verify_skill: + integrity_payload = verify_skill_integrity().to_payload() + integrity_status = str(integrity_payload.get("integrity")) + checks["skill_file_integrity"] = { + "status": ( + "fail" + if integrity_status == INTEGRITY_MODIFIED + else ("warn" if integrity_status == INTEGRITY_OUTDATED else "pass") + ), + **integrity_payload, + } + return { "version": CLI_VERSION, - "checks": { - "cli_available": cli_check, - "pytest_plugin": plugin_check, - "python_version": python_check, - "dependencies": dependency_check, - "directories": directory_check, - }, + "checks": checks, } @@ -182,6 +200,16 @@ def _build_doctor_output(checks: dict[str, Any]) -> dict[str, Any]: errors.append("Feature/test directory access issue") suggestions.append("Check directory permissions") + skill_check = checks_map.get("skill_file_integrity") + if isinstance(skill_check, dict): + integrity = skill_check.get("integrity") + if integrity == INTEGRITY_MODIFIED: + healthy = False + errors.append("Skill file integrity verification failed") + suggestions.append("Run: specleft skill update") + elif integrity == INTEGRITY_OUTDATED: + suggestions.append("Skill file is outdated. Run: specleft skill update") + output = { "healthy": healthy, "version": checks.get("version"), @@ -234,6 +262,19 @@ def _print_doctor_table(checks: dict[str, Any], *, verbose: bool) -> None: click.echo(f"{features_marker} Can read feature directory ({features_dir}/)") click.echo(f"{tests_marker} Can write to test directory (tests/)") + skill_check = checks_map.get("skill_file_integrity") + if isinstance(skill_check, dict): + skill_marker = ( + "✓" + if skill_check.get("status") == "pass" + else ("⚠" if skill_check.get("status") == "warn" else "✗") + ) + click.echo( + f"{skill_marker} Skill file integrity ({skill_check.get('integrity')})" + ) + if skill_check.get("message"): + click.echo(f" {skill_check.get('message')}") + if verbose and plugin_check.get("error"): click.echo(f"pytest plugin error: {plugin_check.get('error')}") @@ -262,9 +303,14 @@ def _print_doctor_table(checks: dict[str, Any], *, verbose: bool) -> None: help="Output format: 'table' or 'json'.", ) @click.option("--verbose", is_flag=True, help="Show detailed diagnostic information.") -def doctor(format_type: str, verbose: bool) -> None: +@click.option( + "--verify-skill", + is_flag=True, + help="Verify .specleft/SKILL.md checksum and template freshness.", +) +def doctor(format_type: str, verbose: bool, verify_skill: bool) -> None: """Verify SpecLeft installation and environment.""" - checks = _build_doctor_checks() + checks = _build_doctor_checks(verify_skill=verify_skill) output = _build_doctor_output(checks) if format_type == "json": diff --git a/src/specleft/commands/init.py b/src/specleft/commands/init.py index b38117b..0788425 100644 --- a/src/specleft/commands/init.py +++ b/src/specleft/commands/init.py @@ -13,8 +13,13 @@ import click -from specleft.templates.skill_template import get_skill_content from specleft.utils.messaging import print_support_footer +from specleft.utils.skill_integrity import ( + SKILL_FILE_PATH, + SKILL_HASH_PATH, + skill_template_hash, + sync_skill_files, +) _PRD_TEMPLATE_CONTENT = """\ version: "1.0" @@ -111,7 +116,6 @@ def _init_plan(example: bool) -> tuple[list[Path], list[tuple[Path, str]]]: if example: for rel_path, content in _init_example_content().items(): files.append((Path(rel_path), content)) - files.append((Path(".specleft/SKILL.md"), get_skill_content())) files.append((Path(".specleft/.gitkeep"), "")) files.append((Path(".specleft/policies/.gitkeep"), "")) files.append((Path(".specleft/templates/prd-template.yml"), _PRD_TEMPLATE_CONTENT)) @@ -129,16 +133,17 @@ def _prompt_init_action(features_dir: Path) -> str: def _print_init_dry_run(directories: list[Path], files: list[tuple[Path, str]]) -> None: + would_create = [path for path, _ in files] + [SKILL_FILE_PATH, SKILL_HASH_PATH] click.echo("Dry run: no files will be created.") click.echo("") click.echo("Would create:") - for file_path, _ in files: + for file_path in would_create: click.echo(f" - {file_path}") for directory in directories: click.echo(f" - {directory}/") click.echo("") click.echo("Summary:") - click.echo(f" {len(files)} files would be created") + click.echo(f" {len(would_create)} files would be created") click.echo(f" {len(directories)} directories would be created") @@ -203,19 +208,6 @@ def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: if blank: example = False - skill_file = Path(".specleft/SKILL.md") - if skill_file.exists(): - warning = "Skipped creation. Specleft SKILL.md exists already." - if format_type == "json": - payload_cancelled = { - "status": "cancelled", - "message": warning, - } - click.echo(json.dumps(payload_cancelled, indent=2)) - else: - click.secho(f"Warning: {warning}", fg="yellow") - sys.exit(2) - features_dir = Path(".specleft/specs") if features_dir.exists(): if format_type == "json": @@ -235,17 +227,20 @@ def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: directories, files = _init_plan(example=example) if dry_run: + would_create = [str(path) for path, _ in files] + would_create.extend([str(SKILL_FILE_PATH), str(SKILL_HASH_PATH)]) if format_type == "json": payload_dry_run = { "status": "ok", "dry_run": True, "example": example, - "would_create": [str(path) for path, _ in files], + "would_create": would_create, "would_create_directories": [str(path) for path in directories], "summary": { - "files": len(files), + "files": len(would_create), "directories": len(directories), }, + "skill_file_hash": skill_template_hash(), } click.echo(json.dumps(payload_dry_run, indent=2)) return @@ -275,11 +270,16 @@ def init(example: bool, blank: bool, dry_run: bool, format_type: str) -> None: ) click.echo("") created = _apply_init_plan(directories, files) - for path in created: - if path.is_dir(): - click.echo(f"✓ Created {path}/") + skill_sync = sync_skill_files(overwrite_existing=False) + for created_path in created: + if created_path.is_dir(): + click.echo(f"✓ Created {created_path}/") else: - click.echo(f"✓ Created {path}") + click.echo(f"✓ Created {created_path}") + for created_skill_path in skill_sync.created: + click.echo(f"✓ Created {created_skill_path}") + for warning in skill_sync.warnings: + click.secho(warning, fg="yellow") click.echo("") _print_license_notice() diff --git a/src/specleft/commands/skill.py b/src/specleft/commands/skill.py new file mode 100644 index 0000000..825cf6f --- /dev/null +++ b/src/specleft/commands/skill.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Skill file lifecycle commands.""" + +from __future__ import annotations + +import json +import sys +from typing import cast + +import click + +from specleft.utils.skill_integrity import ( + INTEGRITY_MODIFIED, + INTEGRITY_OUTDATED, + sync_skill_files, + verify_skill_integrity, +) + + +@click.group("skill") +def skill_group() -> None: + """Manage SpecLeft skill files.""" + + +def _print_integrity_table(payload: dict[str, object]) -> None: + integrity = str(payload.get("integrity")) + marker = "✓" if integrity == "pass" else ("⚠" if integrity == "outdated" else "✗") + click.echo(f"{marker} Skill integrity: {integrity}") + click.echo(f"Skill file: {payload.get('skill_file')}") + click.echo(f"Checksum file: {payload.get('checksum_file')}") + click.echo(f"Expected hash: {payload.get('expected_hash')}") + click.echo(f"Actual hash: {payload.get('actual_hash')}") + click.echo(f"Template hash: {payload.get('current_template_hash')}") + click.echo(f"Commands simple: {payload.get('commands_simple')}") + message = payload.get("message") + if message: + click.echo(f"Message: {message}") + + +@skill_group.command("verify") +@click.option( + "--format", + "format_type", + type=click.Choice(["table", "json"], case_sensitive=False), + default="table", + show_default=True, + help="Output format: 'table' or 'json'.", +) +def skill_verify(format_type: str) -> None: + """Verify SKILL.md integrity and freshness.""" + result = verify_skill_integrity().to_payload() + if format_type == "json": + click.echo(json.dumps(result, indent=2)) + else: + _print_integrity_table(result) + + integrity = str(result["integrity"]) + if integrity == INTEGRITY_MODIFIED: + sys.exit(1) + if integrity == INTEGRITY_OUTDATED: + sys.exit(0) + sys.exit(0) + + +def _print_sync_table(payload: dict[str, object]) -> None: + created = cast(list[str], payload.get("created", [])) + updated = cast(list[str], payload.get("updated", [])) + skipped = cast(list[str], payload.get("skipped", [])) + warnings = cast(list[str], payload.get("warnings", [])) + click.echo("Skill sync complete.") + for entry in created: + click.echo(f"✓ Created {entry}") + for entry in updated: + click.echo(f"✓ Updated {entry}") + for entry in skipped: + click.echo(f"• Skipped {entry}") + for warning in warnings: + click.secho(str(warning), fg="yellow") + click.echo(f"Skill file hash: {payload.get('skill_file_hash')}") + + +@skill_group.command("update") +@click.option( + "--format", + "format_type", + type=click.Choice(["table", "json"], case_sensitive=False), + default="table", + show_default=True, + help="Output format: 'table' or 'json'.", +) +def skill_update(format_type: str) -> None: + """Regenerate SKILL.md and checksum from the current SpecLeft version.""" + payload = sync_skill_files(overwrite_existing=True).to_payload() + if format_type == "json": + click.echo(json.dumps(payload, indent=2)) + else: + _print_sync_table(payload) + sys.exit(0) diff --git a/src/specleft/templates/skill_template.py b/src/specleft/templates/skill_template.py index 4b37f42..85c4248 100644 --- a/src/specleft/templates/skill_template.py +++ b/src/specleft/templates/skill_template.py @@ -17,8 +17,9 @@ def get_skill_content() -> str: 1. specleft next --format json 2. Implement test logic 3. specleft features validate --format json - 4. pytest - 5. Repeat + 4. specleft skill verify --format json + 5. pytest + 6. Repeat ## Safety - Always `--dry-run` before writing files @@ -41,12 +42,12 @@ def get_skill_content() -> str: `specleft features stats --format json [--dir PATH] [--tests-dir PATH]` ### Add a feature - `specleft features add --format json --id FEATURE_ID --title "Title" [--priority critical|high|medium|low] [--description TEXT] [--dir PATH] [--dry-run]` + `specleft features add --format json --id FEATURE_ID --title "Title" [--priority PRIORITY] [--description TEXT] [--dir PATH] [--dry-run]` Creates `/feature-id.md`. Never overwrites existing files. Use `--interactive` for guided prompts (TTY only). ### Add a scenario - `specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority critical|high|medium|low] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test stub|skeleton] [--preview-test]` + `specleft features add-scenario --format json --feature FEATURE_ID --title "Title" [--id SCENARIO_ID] [--step "Given ..."] [--step "When ..."] [--step "Then ..."] [--priority PRIORITY] [--tags "tag1,tag2"] [--dir PATH] [--tests-dir PATH] [--dry-run] [--add-test MODE] [--preview-test]` Appends to feature file. `--add-test` generates a test file. `--preview-test` shows test content without writing. Use `--interactive` for guided prompts (TTY only). @@ -57,7 +58,7 @@ def get_skill_content() -> str: `specleft status --format json [--dir PATH] [--feature ID] [--story ID] [--unimplemented] [--implemented]` ### Next scenario to implement - `specleft next --format json [--dir PATH] [--limit N] [--priority critical|high|medium|low] [--feature ID] [--story ID]` + `specleft next --format json [--dir PATH] [--limit N] [--priority PRIORITY] [--feature ID] [--story ID]` ### Coverage metrics `specleft coverage --format json [--dir PATH] [--threshold N] [--output PATH]` @@ -91,7 +92,21 @@ def get_skill_content() -> str: ### Verify contract `specleft contract test --format json [--verbose]` - Run to verify deterministic/safe command guarantees. + Run to verify deterministic and safe command guarantees. + + ## Skill Security + + ### Verify skill integrity + `specleft skill verify --format json` + Returns `pass`, `modified`, or `outdated` integrity status. + + ### Update skill files + `specleft skill update --format json` + Regenerates `.specleft/SKILL.md` and `.specleft/SKILL.md.sha256`. + + ### Verify within doctor checks + `specleft doctor --verify-skill --format json` + Adds skill integrity status to standard environment diagnostics. ## Enforcement diff --git a/src/specleft/utils/skill_integrity.py b/src/specleft/utils/skill_integrity.py new file mode 100644 index 0000000..99e4f0c --- /dev/null +++ b/src/specleft/utils/skill_integrity.py @@ -0,0 +1,299 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""SKILL.md integrity helpers.""" + +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Literal + +from specleft.templates.skill_template import get_skill_content + +IntegrityStatus = Literal["pass", "modified", "outdated"] +INTEGRITY_PASS: Final[IntegrityStatus] = "pass" +INTEGRITY_MODIFIED: Final[IntegrityStatus] = "modified" +INTEGRITY_OUTDATED: Final[IntegrityStatus] = "outdated" + +SKILL_FILE_PATH = Path(".specleft/SKILL.md") +SKILL_HASH_PATH = Path(".specleft/SKILL.md.sha256") + +_READ_ONLY_MODE = 0o444 +_WRITE_MODE = 0o644 +_SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$") +_METACHARACTERS = ("&&", "||", ";", "|", ">", "<", "$(", "`") +_BACKTICK_COMMAND = re.compile(r"`([^`\n]+)`") + + +def skill_template_hash() -> str: + """Return SHA-256 hash for the canonical SKILL template.""" + return _sha256_hex(get_skill_content()) + + +def _sha256_hex(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _set_read_only(path: Path) -> None: + try: + os.chmod(path, _READ_ONLY_MODE) + except OSError: + return + + +def _set_writeable(path: Path) -> None: + try: + os.chmod(path, _WRITE_MODE) + except OSError: + return + + +def _write_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + _set_writeable(path) + path.write_text(content) + _set_read_only(path) + + +def _read_hash(path: Path) -> str | None: + if not path.exists(): + return None + try: + raw = path.read_text().strip().split() + except OSError: + return None + if not raw: + return None + value = raw[0].lower() + if not _SHA256_PATTERN.fullmatch(value): + return None + return value + + +def _extract_specleft_commands(content: str) -> list[str]: + commands: list[str] = [] + for match in _BACKTICK_COMMAND.findall(content): + command = match.strip() + if command.startswith("specleft "): + commands.append(command) + return commands + + +def _commands_are_simple(commands: list[str]) -> bool: + if not commands: + return False + for command in commands: + if any(token in command for token in _METACHARACTERS): + return False + return True + + +@dataclass +class SkillSyncResult: + """Outcome for skill file synchronization.""" + + created: list[str] + updated: list[str] + skipped: list[str] + warnings: list[str] + skill_file_hash: str + + def to_payload(self) -> dict[str, object]: + return { + "status": "ok", + "created": self.created, + "updated": self.updated, + "skipped": self.skipped, + "warnings": self.warnings, + "skill_file_hash": self.skill_file_hash, + } + + +@dataclass +class SkillIntegrityResult: + """Structured integrity verification result for SKILL.md.""" + + skill_file: str + checksum_file: str + expected_hash: str | None + actual_hash: str | None + current_template_hash: str + integrity: IntegrityStatus + commands_simple: bool + message: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "skill_file": self.skill_file, + "checksum_file": self.checksum_file, + "expected_hash": self.expected_hash, + "actual_hash": self.actual_hash, + "current_template_hash": self.current_template_hash, + "commands_simple": self.commands_simple, + "integrity": self.integrity, + } + if self.message: + payload["message"] = self.message + return payload + + +def sync_skill_files(*, overwrite_existing: bool) -> SkillSyncResult: + """Ensure SKILL.md and checksum file exist and are consistent.""" + created: list[str] = [] + updated: list[str] = [] + skipped: list[str] = [] + warnings: list[str] = [] + + canonical_content = get_skill_content() + canonical_hash = _sha256_hex(canonical_content) + skill_path = SKILL_FILE_PATH + hash_path = SKILL_HASH_PATH + + skill_exists = skill_path.exists() + if not skill_exists: + _write_file(skill_path, canonical_content) + created.append(str(skill_path)) + skill_hash = canonical_hash + elif overwrite_existing: + current_content = skill_path.read_text() + if current_content != canonical_content: + _write_file(skill_path, canonical_content) + updated.append(str(skill_path)) + else: + skipped.append(str(skill_path)) + skill_hash = canonical_hash + else: + skipped.append(str(skill_path)) + warnings.append("Warning: Skipped creation. Specleft SKILL.md exists already.") + skill_hash = _sha256_hex(skill_path.read_text()) + + hash_exists = hash_path.exists() + hash_content = f"{skill_hash}\n" + if not hash_exists: + _write_file(hash_path, hash_content) + created.append(str(hash_path)) + elif overwrite_existing: + current_hash = hash_path.read_text() + if current_hash != hash_content: + _write_file(hash_path, hash_content) + updated.append(str(hash_path)) + else: + skipped.append(str(hash_path)) + else: + skipped.append(str(hash_path)) + + return SkillSyncResult( + created=created, + updated=updated, + skipped=skipped, + warnings=warnings, + skill_file_hash=skill_hash, + ) + + +def verify_skill_integrity() -> SkillIntegrityResult: + """Verify SKILL.md integrity and freshness.""" + skill_path = SKILL_FILE_PATH + hash_path = SKILL_HASH_PATH + template_hash = skill_template_hash() + + if not skill_path.exists(): + return SkillIntegrityResult( + skill_file=str(skill_path), + checksum_file=str(hash_path), + expected_hash=_read_hash(hash_path), + actual_hash=None, + current_template_hash=template_hash, + integrity=INTEGRITY_MODIFIED, + commands_simple=False, + message="Skill file is missing. Run `specleft skill update`.", + ) + + try: + content = skill_path.read_text() + except OSError: + return SkillIntegrityResult( + skill_file=str(skill_path), + checksum_file=str(hash_path), + expected_hash=_read_hash(hash_path), + actual_hash=None, + current_template_hash=template_hash, + integrity=INTEGRITY_MODIFIED, + commands_simple=False, + message="Skill file cannot be read.", + ) + + actual_hash = _sha256_hex(content) + expected_hash = _read_hash(hash_path) + commands = _extract_specleft_commands(content) + commands_simple = _commands_are_simple(commands) + + if expected_hash is None: + return SkillIntegrityResult( + skill_file=str(skill_path), + checksum_file=str(hash_path), + expected_hash=None, + actual_hash=actual_hash, + current_template_hash=template_hash, + integrity=INTEGRITY_MODIFIED, + commands_simple=commands_simple, + message="Skill checksum file is missing or invalid. Run `specleft skill update`.", + ) + + if expected_hash != actual_hash: + return SkillIntegrityResult( + skill_file=str(skill_path), + checksum_file=str(hash_path), + expected_hash=expected_hash, + actual_hash=actual_hash, + current_template_hash=template_hash, + integrity=INTEGRITY_MODIFIED, + commands_simple=commands_simple, + message=( + "Skill file hash mismatch. Run `specleft skill update` to regenerate." + ), + ) + + if not commands_simple: + return SkillIntegrityResult( + skill_file=str(skill_path), + checksum_file=str(hash_path), + expected_hash=expected_hash, + actual_hash=actual_hash, + current_template_hash=template_hash, + integrity=INTEGRITY_MODIFIED, + commands_simple=False, + message=( + "Skill file contains non-simple commands. This can indicate tampering." + ), + ) + + if actual_hash != template_hash: + return SkillIntegrityResult( + skill_file=str(skill_path), + checksum_file=str(hash_path), + expected_hash=expected_hash, + actual_hash=actual_hash, + current_template_hash=template_hash, + integrity=INTEGRITY_OUTDATED, + commands_simple=True, + message=( + "Skill file is valid but outdated for this SpecLeft version. " + "Run `specleft skill update`." + ), + ) + + return SkillIntegrityResult( + skill_file=str(skill_path), + checksum_file=str(hash_path), + expected_hash=expected_hash, + actual_hash=actual_hash, + current_template_hash=template_hash, + integrity=INTEGRITY_PASS, + commands_simple=True, + ) diff --git a/tests/cli/test_cli_base.py b/tests/cli/test_cli_base.py index 55d0d5c..acff45c 100644 --- a/tests/cli/test_cli_base.py +++ b/tests/cli/test_cli_base.py @@ -27,3 +27,4 @@ def test_cli_help(self) -> None: assert "test" in result.output assert "features" in result.output assert "contract" in result.output + assert "skill" in result.output diff --git a/tests/commands/test_contract.py b/tests/commands/test_contract.py index 5209633..fda2132 100644 --- a/tests/commands/test_contract.py +++ b/tests/commands/test_contract.py @@ -20,6 +20,9 @@ def test_contract_json_output(self) -> None: assert payload["contract_version"] == "1.0" assert payload["specleft_version"] == CLI_VERSION assert "guarantees" in payload + skill_security = payload["guarantees"]["skill_security"] + assert skill_security["skill_file_integrity_check"] is True + assert skill_security["skill_file_commands_are_simple"] is True def test_contract_test_json_output(self) -> None: runner = CliRunner() diff --git a/tests/commands/test_doctor.py b/tests/commands/test_doctor.py index 363d601..618d934 100644 --- a/tests/commands/test_doctor.py +++ b/tests/commands/test_doctor.py @@ -72,3 +72,34 @@ def test_doctor_verbose_output(self) -> None: assert result.exit_code in {0, 1} assert "pytest plugin" in result.output assert "feature directory" in result.output + + def test_doctor_verify_skill_json_pass(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + init_result = runner.invoke(cli, ["init"], input="1\n") + assert init_result.exit_code == 0 + + result = runner.invoke( + cli, ["doctor", "--verify-skill", "--format", "json"] + ) + assert result.exit_code in {0, 1} + payload = json.loads(result.output) + skill_check = payload["checks"]["skill_file_integrity"] + assert skill_check["integrity"] == "pass" + assert skill_check["status"] == "pass" + + def test_doctor_verify_skill_json_modified(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path(".specleft").mkdir(parents=True, exist_ok=True) + Path(".specleft/SKILL.md").write_text("# tampered\n") + Path(".specleft/SKILL.md.sha256").write_text("a" * 64 + "\n") + + result = runner.invoke( + cli, ["doctor", "--verify-skill", "--format", "json"] + ) + assert result.exit_code == 1 + payload = json.loads(result.output) + skill_check = payload["checks"]["skill_file_integrity"] + assert skill_check["integrity"] == "modified" + assert skill_check["status"] == "fail" diff --git a/tests/commands/test_init.py b/tests/commands/test_init.py index c90c308..2c14380 100644 --- a/tests/commands/test_init.py +++ b/tests/commands/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import hashlib from pathlib import Path from click.testing import CliRunner @@ -17,9 +18,16 @@ def test_init_creates_single_file_example(self) -> None: with runner.isolated_filesystem(): result = runner.invoke(cli, ["init"], input="1\n") assert result.exit_code == 0 - assert Path(".specleft/SKILL.md").exists() + skill_path = Path(".specleft/SKILL.md") + checksum_path = Path(".specleft/SKILL.md.sha256") + assert skill_path.exists() + assert checksum_path.exists() assert Path(".specleft/specs/example-feature.md").exists() assert Path(".specleft/templates/prd-template.yml").exists() + expected_hash = hashlib.sha256( + skill_path.read_text().encode("utf-8") + ).hexdigest() + assert checksum_path.read_text().strip() == expected_hash def test_init_json_dry_run(self) -> None: runner = CliRunner() @@ -29,9 +37,12 @@ def test_init_json_dry_run(self) -> None: payload = json.loads(result.output) assert payload["dry_run"] is True assert payload["summary"]["directories"] == 5 + assert payload["summary"]["files"] == 6 assert ".specleft/SKILL.md" in payload["would_create"] + assert ".specleft/SKILL.md.sha256" in payload["would_create"] assert ".specleft/specs/example-feature.md" in payload["would_create"] assert ".specleft/templates/prd-template.yml" in payload["would_create"] + assert len(payload["skill_file_hash"]) == 64 def test_init_json_requires_dry_run(self) -> None: runner = CliRunner() @@ -87,28 +98,17 @@ def test_init_existing_features_cancel(self) -> None: assert result.exit_code == 2 assert "Cancelled" in result.output - def test_init_existing_skill_file_warns_and_exits(self) -> None: + def test_init_existing_skill_file_warns_and_continues(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path(".specleft").mkdir(parents=True) Path(".specleft/SKILL.md").write_text("# existing\n") result = runner.invoke(cli, ["init"]) - assert result.exit_code == 2 + assert result.exit_code == 0 assert ( "Warning: Skipped creation. Specleft SKILL.md exists already." in result.output ) - - def test_init_existing_skill_file_json_cancelled(self) -> None: - runner = CliRunner() - with runner.isolated_filesystem(): - Path(".specleft").mkdir(parents=True) - Path(".specleft/SKILL.md").write_text("# existing\n") - result = runner.invoke(cli, ["init", "--format", "json"]) - assert result.exit_code == 2 - payload = json.loads(result.output) - assert payload["status"] == "cancelled" - assert ( - payload["message"] - == "Skipped creation. Specleft SKILL.md exists already." - ) + assert Path(".specleft/specs/example-feature.md").exists() + assert Path(".specleft/SKILL.md").read_text() == "# existing\n" + assert Path(".specleft/SKILL.md.sha256").exists() diff --git a/tests/commands/test_skill.py b/tests/commands/test_skill.py new file mode 100644 index 0000000..423c049 --- /dev/null +++ b/tests/commands/test_skill.py @@ -0,0 +1,94 @@ +"""Tests for 'specleft skill' commands.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +from click.testing import CliRunner +from specleft.cli.main import cli + + +def _hash_for(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +class TestSkillCommand: + """Tests for 'specleft skill' commands.""" + + def test_skill_group_help(self) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["skill", "--help"]) + assert result.exit_code == 0 + assert "verify" in result.output + assert "update" in result.output + + def test_skill_verify_pass_after_init(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + init_result = runner.invoke(cli, ["init"], input="1\n") + assert init_result.exit_code == 0 + + result = runner.invoke(cli, ["skill", "verify", "--format", "json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["integrity"] == "pass" + assert payload["commands_simple"] is True + assert payload["expected_hash"] == payload["actual_hash"] + + def test_skill_verify_modified_when_hash_mismatch(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + init_result = runner.invoke(cli, ["init"], input="1\n") + assert init_result.exit_code == 0 + + skill_path = Path(".specleft/SKILL.md") + os.chmod(skill_path, 0o644) + skill_path.write_text("# tampered\n") + + result = runner.invoke(cli, ["skill", "verify", "--format", "json"]) + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["integrity"] == "modified" + + def test_skill_verify_outdated_when_hash_matches_noncanonical(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path(".specleft").mkdir(parents=True, exist_ok=True) + outdated_content = ( + "# SpecLeft CLI Reference\n\n" + "## Workflow\n" + "1. `specleft status --format json`\n" + ) + Path(".specleft/SKILL.md").write_text(outdated_content) + Path(".specleft/SKILL.md.sha256").write_text( + f"{_hash_for(outdated_content)}\n" + ) + + result = runner.invoke(cli, ["skill", "verify", "--format", "json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["integrity"] == "outdated" + + def test_skill_update_repairs_modified_integrity(self) -> None: + runner = CliRunner() + with runner.isolated_filesystem(): + Path(".specleft").mkdir(parents=True, exist_ok=True) + Path(".specleft/SKILL.md").write_text("# tampered\n") + Path(".specleft/SKILL.md.sha256").write_text( + f"{_hash_for('# different')} \n" + ) + + update_result = runner.invoke(cli, ["skill", "update", "--format", "json"]) + assert update_result.exit_code == 0 + update_payload = json.loads(update_result.output) + assert ".specleft/SKILL.md" in ( + update_payload["updated"] + update_payload["created"] + ) + + verify_result = runner.invoke(cli, ["skill", "verify", "--format", "json"]) + assert verify_result.exit_code == 0 + verify_payload = json.loads(verify_result.output) + assert verify_payload["integrity"] == "pass" From 587025af3401f991d4f03355c4a3554246e8faaa Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 17:13:57 +0000 Subject: [PATCH 3/4] Add skill-integrity feature mapping (#95) --- features/feature-skill-integrity.md | 41 +++++++++++++++++++++++++++++ tests/commands/test_skill.py | 21 +++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 features/feature-skill-integrity.md diff --git a/features/feature-skill-integrity.md b/features/feature-skill-integrity.md new file mode 100644 index 0000000..c350ee3 --- /dev/null +++ b/features/feature-skill-integrity.md @@ -0,0 +1,41 @@ +# Feature: Skill File Integrity Verification + +## Scenarios + +### Scenario: Skill command group is discoverable +priority: high + +- Given the SpecLeft CLI is available +- When `specleft skill --help` is invoked +- Then the output lists `verify` and `update` subcommands + +### Scenario: Verify reports pass after init +priority: high + +- Given `specleft init` has generated `.specleft/SKILL.md` +- And a matching `.specleft/SKILL.md.sha256` exists +- When `specleft skill verify --format json` is invoked +- Then the integrity status is `pass` + +### Scenario: Verify reports modified on hash mismatch +priority: high + +- Given `.specleft/SKILL.md` content is modified after generation +- And `.specleft/SKILL.md.sha256` still has the previous hash +- When `specleft skill verify --format json` is invoked +- Then the integrity status is `modified` + +### Scenario: Verify reports outdated for non-canonical but checksum-valid content +priority: medium + +- Given `.specleft/SKILL.md` matches its checksum file +- But the content differs from the current packaged template +- When `specleft skill verify --format json` is invoked +- Then the integrity status is `outdated` + +### Scenario: Skill update repairs modified integrity state +priority: high + +- Given `.specleft/SKILL.md` and `.specleft/SKILL.md.sha256` are inconsistent +- When `specleft skill update --format json` is invoked +- Then `specleft skill verify --format json` reports integrity `pass` diff --git a/tests/commands/test_skill.py b/tests/commands/test_skill.py index 423c049..bc3d67e 100644 --- a/tests/commands/test_skill.py +++ b/tests/commands/test_skill.py @@ -9,6 +9,7 @@ from click.testing import CliRunner from specleft.cli.main import cli +from specleft.decorators import specleft def _hash_for(content: str) -> str: @@ -18,6 +19,10 @@ def _hash_for(content: str) -> str: class TestSkillCommand: """Tests for 'specleft skill' commands.""" + @specleft( + feature_id="feature-skill-integrity", + scenario_id="skill-command-group-is-discoverable", + ) def test_skill_group_help(self) -> None: runner = CliRunner() result = runner.invoke(cli, ["skill", "--help"]) @@ -25,6 +30,10 @@ def test_skill_group_help(self) -> None: assert "verify" in result.output assert "update" in result.output + @specleft( + feature_id="feature-skill-integrity", + scenario_id="verify-reports-pass-after-init", + ) def test_skill_verify_pass_after_init(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): @@ -38,6 +47,10 @@ def test_skill_verify_pass_after_init(self) -> None: assert payload["commands_simple"] is True assert payload["expected_hash"] == payload["actual_hash"] + @specleft( + feature_id="feature-skill-integrity", + scenario_id="verify-reports-modified-on-hash-mismatch", + ) def test_skill_verify_modified_when_hash_mismatch(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): @@ -53,6 +66,10 @@ def test_skill_verify_modified_when_hash_mismatch(self) -> None: payload = json.loads(result.output) assert payload["integrity"] == "modified" + @specleft( + feature_id="feature-skill-integrity", + scenario_id="verify-reports-outdated-for-non-canonical-but-checksum-valid-content", + ) def test_skill_verify_outdated_when_hash_matches_noncanonical(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): @@ -72,6 +89,10 @@ def test_skill_verify_outdated_when_hash_matches_noncanonical(self) -> None: payload = json.loads(result.output) assert payload["integrity"] == "outdated" + @specleft( + feature_id="feature-skill-integrity", + scenario_id="skill-update-repairs-modified-integrity-state", + ) def test_skill_update_repairs_modified_integrity(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): From 5366037b3571330bd21a26443e2fe8dccdd99e2d Mon Sep 17 00:00:00 2001 From: Richard-Otterli Date: Sun, 15 Feb 2026 17:51:36 +0000 Subject: [PATCH 4/4] Update contract v1.1 skill security summary (#95) --- README.md | 2 ++ docs/SKILL.md | 6 +++++- src/specleft/commands/constants.py | 2 +- src/specleft/commands/contracts/table.py | 13 +++++++++++++ tests/commands/test_contract.py | 4 ++-- tests/commands/test_contract_table.py | 7 ++++++- 6 files changed, 29 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 155ebd8..04a9dff 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Guaranteed invariants: - All validation errors are explicit and actionable - Missing behaviour is skipped, never failed - CLI exit codes are meaningful and stable +- Skill instructions are integrity-verifiable via `specleft skill verify` +- Skill commands are constrained to simple `specleft ...` invocations (no shell metacharacters) SpecLeft commands are safe to: - run repeatedly diff --git a/docs/SKILL.md b/docs/SKILL.md index d888107..f6a3c64 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -81,7 +81,11 @@ Builds an HTML report from `.specleft/results/`. `specleft contract test --format json [--verbose]` Run to verify deterministic and safe command guarantees. -## Skill Security +## Skill Command Group + +### Show skill commands +`specleft skill --help` +Displays skill lifecycle subcommands (`verify`, `update`). ### Verify skill integrity `specleft skill verify --format json` diff --git a/src/specleft/commands/constants.py b/src/specleft/commands/constants.py index c6d908e..68b9996 100644 --- a/src/specleft/commands/constants.py +++ b/src/specleft/commands/constants.py @@ -8,5 +8,5 @@ from specleft.version import SPECLEFT_VERSION CLI_VERSION = SPECLEFT_VERSION -CONTRACT_VERSION = "1.0" +CONTRACT_VERSION = "1.1" CONTRACT_DOC_PATH = "docs/agent-contract.md" diff --git a/src/specleft/commands/contracts/table.py b/src/specleft/commands/contracts/table.py index 7760939..8a68616 100644 --- a/src/specleft/commands/contracts/table.py +++ b/src/specleft/commands/contracts/table.py @@ -20,6 +20,7 @@ def print_contract_table(payload: Mapping[str, object]) -> None: execution = cast(dict[str, Any], guarantees.get("execution", {})) determinism = cast(dict[str, Any], guarantees.get("determinism", {})) cli_api = cast(dict[str, Any], guarantees.get("cli_api", {})) + skill_security = cast(dict[str, Any], guarantees.get("skill_security", {})) click.echo("SpecLeft Agent Contract") click.echo("─" * 40) click.echo(f"Contract version: {payload.get('contract_version')}") @@ -84,6 +85,18 @@ def print_contract_table(payload: Mapping[str, object]) -> None: ) click.echo(" - Stable exit codes: 0=success, 1=error, 2=cancel") click.echo("") + click.echo("Skill Security:") + click.echo( + " - Skill file integrity is verifiable" + if skill_security.get("skill_file_integrity_check") + else " - Skill integrity guarantee missing" + ) + click.echo( + " - Skill commands are simple invocations (no shell metacharacters)" + if skill_security.get("skill_file_commands_are_simple") + else " - Skill command simplicity guarantee missing" + ) + click.echo("") click.echo(f"For full details, see: {CONTRACT_DOC_PATH}") click.echo("─" * 40) diff --git a/tests/commands/test_contract.py b/tests/commands/test_contract.py index fda2132..370f90d 100644 --- a/tests/commands/test_contract.py +++ b/tests/commands/test_contract.py @@ -17,7 +17,7 @@ def test_contract_json_output(self) -> None: result = runner.invoke(cli, ["contract", "--format", "json"]) assert result.exit_code == 0 payload = json.loads(result.output) - assert payload["contract_version"] == "1.0" + assert payload["contract_version"] == "1.1" assert payload["specleft_version"] == CLI_VERSION assert "guarantees" in payload skill_security = payload["guarantees"]["skill_security"] @@ -29,7 +29,7 @@ def test_contract_test_json_output(self) -> None: result = runner.invoke(cli, ["contract", "test", "--format", "json"]) assert result.exit_code == 0 payload = json.loads(result.output) - assert payload["contract_version"] == "1.0" + assert payload["contract_version"] == "1.1" assert payload["specleft_version"] == CLI_VERSION assert payload["passed"] is True assert payload["checks"] diff --git a/tests/commands/test_contract_table.py b/tests/commands/test_contract_table.py index 4c699ca..96767a5 100644 --- a/tests/commands/test_contract_table.py +++ b/tests/commands/test_contract_table.py @@ -36,7 +36,7 @@ def test_print_contract_table_outputs_sections( capsys: pytest.CaptureFixture[str], ) -> None: payload = { - "contract_version": "1.0", + "contract_version": "1.1", "specleft_version": "0.2.0", "guarantees": { "safety": { @@ -57,6 +57,10 @@ def test_print_contract_table_outputs_sections( "json_supported_globally": True, "json_additive_within_minor": True, }, + "skill_security": { + "skill_file_integrity_check": True, + "skill_file_commands_are_simple": True, + }, }, } print_contract_table(payload) @@ -66,6 +70,7 @@ def test_print_contract_table_outputs_sections( assert "Execution:" in output assert "Determinism:" in output assert "JSON & CLI:" in output + assert "Skill Security:" in output def test_print_contract_test_summary(capsys: pytest.CaptureFixture[str]) -> None: