From 62356e5d774660fda04985c327f1e245a5a3153f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:01:33 +0300 Subject: [PATCH 1/4] docs(02): plan manifest inventory and safety boundaries --- .planning/STATE.md | 5 +- .../02-01-PLAN.md | 84 ++++++++++++++++ .../02-02-PLAN.md | 82 +++++++++++++++ .../02-03-PLAN.md | 84 ++++++++++++++++ .../02-CONTEXT.md | 99 +++++++++++++++++++ .../02-RESEARCH.md | 61 ++++++++++++ 6 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-PLAN.md create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-PLAN.md create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-PLAN.md create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-CONTEXT.md create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-RESEARCH.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 6d5b6c8..6eefc74 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,13 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: ready_to_plan -last_updated: 2026-06-11T10:36:59.864Z +last_updated: "2026-06-11T11:01:17.107Z" progress: total_phases: 7 completed_phases: 1 - total_plans: 3 + total_plans: 6 completed_plans: 3 percent: 14 -stopped_at: Phase 1 complete (3/3) — ready to discuss Phase 2 --- # Project State diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-PLAN.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-PLAN.md new file mode 100644 index 0000000..fdc9752 --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-PLAN.md @@ -0,0 +1,84 @@ +--- +phase: 02-manifest-inventory-and-safety-boundaries +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - stack.manifest.json + - schemas/manifest.schema.json + - tests/fixtures/contracts/manifest.valid.json + - tests/fixtures/contracts/manifest.invalid.json + - scripts/Cas.Workstation.psm1 + - tests/Manifest.Tests.ps1 +autonomous: true +requirements: + - MAN-01 + - MAN-02 + - MAN-03 + - MAN-04 + - MAN-05 +must_haves: + truths: + - "D-01 D-02 D-03: Invalid, ambiguous, or unallowlisted manifest content fails before operational external process execution." + - "D-04: Profile resolution and desired-state digest are deterministic and inspectable." + - "D-05 D-06: Compatibility and inventory findings are structured and never claim pre-existing resources." + artifacts: + - path: "scripts/Cas.Workstation.psm1" + provides: "Validated manifest resolution, digest, and compatibility inventory" + contains: "Resolve-CasDesiredState" + - path: "tests/Manifest.Tests.ps1" + provides: "Manifest, allowlist, determinism, and compatibility regression coverage" + contains: "Describe" + key_links: + - from: "scripts/Cas.Workstation.psm1" + to: "schemas/manifest.schema.json" + via: "strict manifest contract and semantic validation" + pattern: "Test-CasManifest" +--- + + +Establish strict declarative manifest resolution and structured compatibility inventory. + +Purpose: No later planner or mutation path may consume ambiguous or untrusted desired state. +Output: Strengthened manifest contract, semantic validator, normalized resolved state, deterministic digest, compatibility findings, and tests. + + + +@.planning/phases/02-manifest-inventory-and-safety-boundaries/02-CONTEXT.md +@.planning/phases/02-manifest-inventory-and-safety-boundaries/02-RESEARCH.md +@stack.manifest.json +@scripts/Cas.Workstation.psm1 + + + + + + Task 1: Strengthen manifest contract and fail-closed semantic validation + stack.manifest.json, schemas/manifest.schema.json, tests/fixtures/contracts/manifest.*.json, scripts/Cas.Workstation.psm1, tests/Manifest.Tests.ps1 + Make profile categories explicitly required/optional, add declarative services, skills, and workspaces, tighten installer/repository/command/target contracts, and implement semantic checks for unique IDs, trusted identities, and resolvable references. Ensure Get-CasManifest validates before returning content. + Invoke-Pester tests/Manifest.Tests.ps1 + Malformed and unallowlisted content fails with actionable errors before operational process execution. + + + + Task 2: Resolve deterministic desired state and compatibility findings + scripts/Cas.Workstation.psm1, tests/Manifest.Tests.ps1 + Implement normalized profile resolution across all categories, canonical JSON plus SHA-256 digest, and structured host/tool compatibility findings. Preserve observed-only semantics for resources that already exist. + Invoke-Pester tests/Manifest.Tests.ps1 + Equivalent manifests resolve identically and required unsupported or unknown compatibility is surfaced fail closed. + + + + + +- [ ] Manifest tests pass. +- [ ] Contract fixtures pass. +- [ ] Desired-state digest is deterministic. + + + +- MAN-01 through MAN-05 are satisfied. + + +Create `02-01-SUMMARY.md` after execution. diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-PLAN.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-PLAN.md new file mode 100644 index 0000000..860b63d --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-PLAN.md @@ -0,0 +1,82 @@ +--- +phase: 02-manifest-inventory-and-safety-boundaries +plan: 02 +type: execute +wave: 2 +depends_on: + - "02-01" +files_modified: + - schemas/managed-state.schema.json + - tests/fixtures/contracts/managed-state.valid.json + - tests/fixtures/contracts/managed-state.invalid.json + - scripts/Cas.Workstation.psm1 + - tests/Safety.Tests.ps1 +autonomous: true +requirements: + - SAFE-01 + - SAFE-02 + - SAFE-04 + - SAFE-05 +must_haves: + truths: + - "D-07: Mutation targets outside approved boundaries, at forbidden roots, or behind reparse points are rejected." + - "D-08 D-06: The versioned ledger distinguishes created, modified, and observed resources without claiming pre-existing state." + - "D-09: Existing user-owned files are backed up and replaced atomically only after validation." + artifacts: + - path: "scripts/Cas.Workstation.psm1" + provides: "Canonical path policy, ownership ledger, and atomic JSON writes" + contains: "Test-CasPathBoundary" + - path: "tests/Safety.Tests.ps1" + provides: "Filesystem and ownership failure-path coverage" + contains: "Describe" + key_links: + - from: "scripts/Cas.Workstation.psm1" + to: "schemas/managed-state.schema.json" + via: "ledger serialization contract" + pattern: "Write-CasManagedState" +--- + + +Establish reusable filesystem boundary, ownership-ledger, and atomic-write safety primitives. + +Purpose: Every later mutation and removal must be constrained by current path evidence and explicit ownership. +Output: Strict managed-state contract, path policy, atomic state/file helpers, and failure-path tests. + + + +@.planning/phases/02-manifest-inventory-and-safety-boundaries/02-CONTEXT.md +@.planning/phases/02-manifest-inventory-and-safety-boundaries/02-RESEARCH.md +@schemas/managed-state.schema.json +@scripts/Cas.Workstation.psm1 + + + + + + Task 1: Implement canonical path and forbidden-root policy + scripts/Cas.Workstation.psm1, tests/Safety.Tests.ps1 + Implement PowerShell 5.1-compatible canonicalization and boundary checks that require strict containment, reject drive/profile/system roots and traversal, and reject existing reparse-point targets or ancestors. Add isolated failure-path tests. + Invoke-Pester tests/Safety.Tests.ps1 + Unsafe paths fail before mutation with actionable errors. + + + + Task 2: Strengthen ledger and atomic backup-aware writes + schemas/managed-state.schema.json, tests/fixtures/contracts/managed-state.*.json, scripts/Cas.Workstation.psm1, tests/Safety.Tests.ps1 + Add explicit resource ownership and backup metadata, implement ledger validation/read/write helpers, prevent created ownership for pre-existing targets, and implement validated sibling-temp atomic JSON/file replacement with recoverable backup evidence. + Invoke-Pester tests/Safety.Tests.ps1; .\scripts\Test-CasJsonSchema.ps1 -AllFixtures + Ledger and file writes are atomic, validated, and cannot claim unrelated pre-existing resources. + + + + + +- [ ] Safety tests pass without touching real workstation paths. +- [ ] Managed-state schema fixtures pass. + + + +- SAFE-01, SAFE-02, SAFE-04, and SAFE-05 are satisfied. + + +Create `02-02-SUMMARY.md` after execution. diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-PLAN.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-PLAN.md new file mode 100644 index 0000000..cc669db --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-PLAN.md @@ -0,0 +1,84 @@ +--- +phase: 02-manifest-inventory-and-safety-boundaries +plan: 03 +type: execute +wave: 3 +depends_on: + - "02-01" + - "02-02" +files_modified: + - uninstall.ps1 + - scripts/Cas.Workstation.psm1 + - tests/Uninstall.Tests.ps1 + - docs/traceability.json + - README.md +autonomous: true +requirements: + - SAFE-03 +must_haves: + truths: + - "D-10: Uninstall defaults to preview and requires explicit apply intent." + - "D-10 D-11: Uninstall acts only on ledger-owned resources that pass current path policy." + - "D-11: Missing, malformed, observed-only, or unsafe evidence blocks removal without touching user state." + artifacts: + - path: "uninstall.ps1" + provides: "Preview-first explicit uninstall entry point" + contains: "Apply" + - path: "tests/Uninstall.Tests.ps1" + provides: "Ledger-only uninstall preview and apply coverage" + contains: "Describe" + - path: "docs/traceability.json" + provides: "Phase 2 requirement evidence" + contains: "SAFE-03" + key_links: + - from: "uninstall.ps1" + to: "scripts/Cas.Workstation.psm1" + via: "ledger-only uninstall domain functions" + pattern: "Get-CasUninstallPreview" +--- + + +Replace arbitrary recursive uninstall with a preview-first ledger-only workflow and complete Phase 2 evidence. + +Purpose: A user must be able to inspect removal intent and trust that unrelated state cannot enter removal scope. +Output: Safe uninstall functions and entry point, failure-path tests, documentation, and traceability evidence. + + + +@.planning/phases/02-manifest-inventory-and-safety-boundaries/02-CONTEXT.md +@.planning/phases/02-manifest-inventory-and-safety-boundaries/02-RESEARCH.md +@uninstall.ps1 +@scripts/Cas.Workstation.psm1 + + + + + + Task 1: Implement ledger-only uninstall preview and explicit apply + uninstall.ps1, scripts/Cas.Workstation.psm1, tests/Uninstall.Tests.ps1 + Make preview the default, require an explicit Apply switch for mutation, load and validate the ownership ledger, reject observed or unsafe resources, revalidate path policy immediately before apply, restore modified resources only from recorded backups, and remove created resources in safe child-before-parent order. + Invoke-Pester tests/Uninstall.Tests.ps1 + Uninstall cannot remove arbitrary roots or unrelated resources and fails closed on unsafe evidence. + + + + Task 2: Complete Phase 2 documentation and evidence + README.md, docs/traceability.json + Document desired-state inspection, safety boundaries, managed-state location, and preview/apply uninstall usage. Mark all Phase 2 requirements verified in traceability with direct tests and evidence commands. + .\Invoke-Quality.ps1 + Phase 2 behavior is documented, traceable, and enforced by the full quality gate. + + + + + +- [ ] Uninstall tests prove preview default, explicit apply, ledger-only scope, and fail-closed behavior. +- [ ] Full quality gate passes. +- [ ] `git diff --check` passes. + + + +- SAFE-03 is satisfied and all Phase 2 requirements have executable evidence. + + +Create `02-03-SUMMARY.md` after execution. diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-CONTEXT.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-CONTEXT.md new file mode 100644 index 0000000..cadca19 --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-CONTEXT.md @@ -0,0 +1,99 @@ +# Phase 2: Manifest, Inventory, and Safety Boundaries - Context + +**Gathered:** 2026-06-11 +**Status:** Ready for planning + + +## Phase Boundary + +Phase 2 establishes the fail-closed contracts and reusable domain functions that resolve declarative desired state, inventory compatibility, validate filesystem boundaries, record ownership, and restrict uninstall to ledger-owned resources. Transactional setup, upgrade, repair, and broad client configuration mutation remain later-phase work. + + + + +## Implementation Decisions + +### Manifest Resolution and Allowlists +- **D-01:** Manifest JSON parsing and semantic validation must finish before any operational external process can run; malformed, unknown, duplicate, or unresolved identifiers fail with actionable errors. +- **D-02:** Profiles explicitly separate required and optional tools, repositories, services, clients, skills, and workspaces; resolution emits a normalized deterministic desired-state object. +- **D-03:** Installer kinds, package identities, repository URLs, command names, and configuration targets use deny-by-default allowlists encoded in the manifest contract and semantic validator. +- **D-04:** Desired-state digest is SHA-256 over canonical UTF-8 JSON with stable property and item ordering. + +### Inventory and Compatibility +- **D-05:** Compatibility checks return structured findings for host OS, PowerShell version, architecture, dependencies, and tool versions; unsupported or unknown required compatibility fails closed. +- **D-06:** Existing resources are inventoried as observed and never automatically claimed as CAS-owned. + +### Filesystem and Ownership Safety +- **D-07:** Every filesystem mutation target must be canonical, inside an explicitly approved CAS boundary, outside forbidden system/profile/drive roots, and free of existing reparse-point ancestors. +- **D-08:** Managed state is a versioned ownership ledger written atomically under the configured CAS state path; it distinguishes created, modified, and observed resources. +- **D-09:** User-owned files may be modified only after a recoverable backup is recorded and the replacement payload validates before atomic replacement. + +### Uninstall +- **D-10:** Uninstall defaults to preview, requires explicit apply intent, and may remove or restore only resources proven by the ledger and revalidated against current path policy. +- **D-11:** Missing, malformed, ambiguous, or unsafe ownership evidence blocks uninstall rather than widening removal scope. + +### the agent's Discretion +- Exact internal PowerShell function decomposition and user-facing wording, provided behavior remains PowerShell 5.1-compatible, testable, deterministic, and fail closed. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Product and Requirements +- `.planning/ROADMAP.md` - Phase 2 goal and success criteria. +- `.planning/REQUIREMENTS.md` - MAN-01 through MAN-05 and SAFE-01 through SAFE-05. +- `.planning/PROJECT.md` - Product safety, configuration, and state constraints. +- `AGENTS.md` - Repository engineering and verification rules. + +### Existing Contracts and Implementation +- `stack.manifest.json` - Current declarative seed manifest. +- `schemas/manifest.schema.json` - Existing manifest contract to strengthen. +- `schemas/managed-state.schema.json` - Existing ownership contract to strengthen. +- `scripts/Cas.Workstation.psm1` - Existing manifest, inventory, and mutation functions. +- `uninstall.ps1` - Existing unsafe recursive-removal entry point to replace. + + + + +## Existing Code Insights + +### Reusable Assets +- `Get-CasManifest`, `Get-CasProfile`, and profile lookup helpers provide the integration surface for validated resolution. +- `Get-CasToolStatus` and `Get-CasRepoStatus` provide seed inventory behavior to wrap in compatibility findings. +- Phase 1 schema fixtures, Pester suite, and `Invoke-Quality.ps1` provide executable contract and verification infrastructure. + +### Established Patterns +- Public behavior lives in `scripts/Cas.Workstation.psm1` and is exposed through thin root scripts. +- JSON contracts use Draft 2020-12 schemas and positive/negative fixtures. +- Full quality validation is repository-local and non-interactive. + +### Integration Points +- `setup.ps1`, `upgrade.ps1`, `doctor.ps1`, and `uninstall.ps1` import the shared module. +- Managed state belongs below the manifest-configured state directory. +- Phase 3 will consume resolved desired state, inventory, and safety policies when it builds plans and applies mutations. + + + + +## Specific Ideas + +- Preserve unrelated user state even when the ledger or target paths are damaged. +- Favor structured result objects that later plan/apply and doctor workflows can consume. + + + + +## Deferred Ideas + +- Transactional operation planning, apply, resume, retry, and rollback belong to Phase 3. +- Client-native merge adapters and skill/workspace installation belong to Phase 4. + + + +--- + +*Phase: 02-manifest-inventory-and-safety-boundaries* +*Context gathered: 2026-06-11* diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-RESEARCH.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-RESEARCH.md new file mode 100644 index 0000000..398be15 --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-RESEARCH.md @@ -0,0 +1,61 @@ +# Phase 2: Manifest, Inventory, and Safety Boundaries - Research + +## Planning Question + +How can CAS establish desired-state and ownership evidence that remains deterministic, inspectable, and safe enough to constrain every later workstation mutation? + +## Recommended Approach + +Build Phase 2 as three dependency-ordered layers: + +1. Strengthen the manifest contract and implement semantic validation, normalized profile resolution, deterministic digesting, and structured compatibility inventory. +2. Implement canonical path policy, a versioned ownership ledger, and validated atomic writes with backups. +3. replace arbitrary recursive uninstall with ledger-only preview and explicit apply. + +Keep these functions side-effect-light and independently testable. Phase 2 should not build the broader transactional operation engine; it should provide the safety and evidence primitives that engine must use. + +## Manifest and Resolution + +JSON Schema should reject unknown structural content, while PowerShell semantic validation should enforce relationships that schema cannot express cleanly: unique IDs, valid profile references, trusted repository origins, command identity, installer/package allowlists, and safe relative configuration targets. + +Resolution should normalize all profile categories into required/optional entries, sort deterministically, and emit only explicit declarative data. Digest canonical UTF-8 JSON rather than raw source JSON so whitespace and property-order differences do not change desired identity. + +## Compatibility and Inventory + +Compatibility is evidence, not mutation. Return structured checks with `supported`, `unsupported`, or `unknown` status and actionable messages. Required unknowns fail closed. Existing tools, repositories, and files remain `observed`; CAS ownership begins only when a later CAS operation creates or explicitly modifies a resource and records evidence. + +## Path Safety + +Use `System.IO.Path.GetFullPath()` for lexical canonicalization, compare paths case-insensitively on Windows, and require a target to be strictly below an approved boundary. Reject drive roots, the user profile root, Windows and Program Files roots, traversal, and any existing target or ancestor carrying the `ReparsePoint` attribute. Revalidate immediately before mutation because paths can change after preview. + +## Ownership and Atomic Writes + +The ledger is authoritative for removal scope but is not sufficient alone: every target must also pass path policy at apply time. State writes use a sibling temporary file, validate the serialized content, then atomically replace or move it. Existing user-owned files require a backup record before replacement. Never mark a pre-existing resource as `created`. + +## Uninstall + +Preview is the default and must work without mutation. Apply requires an explicit switch and ShouldProcess confirmation. The uninstall operation loads and validates managed state, filters to actionable ownership classes, validates every path, and blocks the entire apply if any resource is ambiguous or unsafe. Modified user-owned files restore a recorded backup; observed resources are never removed. + +## Threat Model + +| Threat | Severity | Mitigation | +|---|---|---| +| Malicious manifest injects a command, package, repository, or path | Critical | Strict schema plus deny-by-default semantic allowlists before operational processes | +| Path traversal or junction redirects deletion outside CAS roots | Critical | Canonical boundary validation and reparse-point ancestor rejection at preview and apply | +| Corrupt ledger widens uninstall scope | Critical | Strict ledger validation and all-or-nothing fail-closed preview/apply | +| CAS claims pre-existing resources and later removes them | Critical | Inventory records `observed`; ownership claims require explicit creation/modification evidence | +| Interrupted state/config write corrupts user or CAS state | High | Validated sibling temp file, backup, atomic replacement, and cleanup | + +## Verification Strategy + +- Add focused Pester tests for valid and invalid manifest semantics, deterministic resolution/digest, compatibility outcomes, canonical paths, forbidden roots, traversal, reparse points, ownership rules, atomic write failure, uninstall preview, and explicit apply. +- Strengthen schema fixtures for the new manifest and ledger shape. +- Run the full `Invoke-Quality.ps1` gate and `git diff --check`. +- Keep tests isolated under Pester `TestDrive`; never mutate real user or system paths. + +## Planning Implications + +- Plan 02-01 establishes contract and desired-state primitives. +- Plan 02-02 depends on those contracts and establishes mutation safety primitives. +- Plan 02-03 depends on both and integrates ledger-only uninstall plus traceability. + From cf88e7aa4258fa54dd9822b36d734d75637a5f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:05:51 +0300 Subject: [PATCH 2/4] feat(02-01): validate and resolve desired state --- .../02-01-SUMMARY.md | 34 +++ schemas/manifest.schema.json | 28 ++- scripts/Cas.Workstation.psm1 | 230 +++++++++++++++++- stack.manifest.json | 84 +++---- tests/Manifest.Tests.ps1 | 58 +++++ tests/fixtures/contracts/manifest.valid.json | 2 +- 6 files changed, 372 insertions(+), 64 deletions(-) create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-SUMMARY.md create mode 100644 tests/Manifest.Tests.ps1 diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-SUMMARY.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-SUMMARY.md new file mode 100644 index 0000000..05f2c73 --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-01-SUMMARY.md @@ -0,0 +1,34 @@ +--- +phase: 02-manifest-inventory-and-safety-boundaries +plan: 01 +subsystem: manifest +tags: [powershell, json-schema, desired-state, compatibility] +requires: [01-governance-and-quality-foundation] +provides: [validated-manifest, deterministic-desired-state, compatibility-inventory] +affects: [02-02, 02-03, phase-3] +key-files: + created: [tests/Manifest.Tests.ps1] + modified: [stack.manifest.json, schemas/manifest.schema.json, scripts/Cas.Workstation.psm1] +key-decisions: + - "Semantic validation enforces deny-by-default operational identities before manifest use." + - "Desired-state digest is SHA-256 over canonical normalized JSON." +requirements-completed: [MAN-01, MAN-02, MAN-03, MAN-04, MAN-05] +completed: 2026-06-11 +--- + +# Phase 2 Plan 1: Manifest Resolution Summary + +Strict declarative profiles now resolve all six resource categories into deterministic desired state with fail-closed allowlist and compatibility evidence. + +## Verification + +- Manifest Pester tests: 7/7 passed. +- Full Pester regression: 18/18 passed. +- JSON schema fixtures: passed. +- `git diff --check`: passed. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Self-Check: PASSED diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 6693cea..63ff01a 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -4,22 +4,32 @@ "title": "CAS Workstation Manifest", "type": "object", "additionalProperties": false, - "required": ["manifestVersion", "bundleName", "bundleId", "defaults", "profiles", "paths", "tools", "repos", "clients", "sharedMcpServer"], + "required": ["manifestVersion", "bundleName", "bundleId", "defaults", "policy", "profiles", "paths", "tools", "repos", "services", "clients", "skills", "workspaces", "sharedMcpServer"], "properties": { "manifestVersion": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, "bundleName": { "type": "string", "minLength": 1 }, "bundleId": { "type": "string", "pattern": "^[a-z0-9-]+$" }, - "defaults": { "type": "object", "required": ["rootPath", "configPath", "profile"], "properties": { "rootPath": { "type": "string" }, "configPath": { "type": "string" }, "profile": { "type": "string" } }, "additionalProperties": false }, + "defaults": { "type": "object", "required": ["rootPath", "configPath", "profile"], "properties": { "rootPath": { "type": "string", "minLength": 1 }, "configPath": { "type": "string", "minLength": 1 }, "profile": { "type": "string", "minLength": 1 } }, "additionalProperties": false }, + "policy": { "$ref": "#/$defs/policy" }, "profiles": { "type": "object", "minProperties": 1, "additionalProperties": { "$ref": "#/$defs/profile" } }, "paths": { "type": "object", "minProperties": 1, "additionalProperties": { "type": "string", "minLength": 1 } }, - "tools": { "type": "array", "items": { "$ref": "#/$defs/identified" } }, - "repos": { "type": "array", "items": { "$ref": "#/$defs/identified" } }, - "clients": { "type": "array", "items": { "$ref": "#/$defs/identified" } }, - "sharedMcpServer": { "type": "object", "required": ["name", "transport", "command", "args"], "properties": { "name": { "type": "string" }, "transport": { "enum": ["stdio", "http", "sse"] }, "command": { "type": "string" }, "args": { "type": "array", "items": { "type": "string" } } }, "additionalProperties": false } + "tools": { "type": "array", "items": { "$ref": "#/$defs/tool" } }, + "repos": { "type": "array", "items": { "$ref": "#/$defs/repository" } }, + "services": { "type": "array", "items": { "$ref": "#/$defs/identified" } }, + "clients": { "type": "array", "items": { "$ref": "#/$defs/client" } }, + "skills": { "type": "array", "items": { "$ref": "#/$defs/identified" } }, + "workspaces": { "type": "array", "items": { "$ref": "#/$defs/workspace" } }, + "sharedMcpServer": { "type": "object", "required": ["name", "transport", "command", "args"], "properties": { "name": { "type": "string", "minLength": 1 }, "transport": { "enum": ["stdio", "http", "sse"] }, "command": { "type": "string", "minLength": 1 }, "args": { "type": "array", "items": { "type": "string" } } }, "additionalProperties": false } }, "$defs": { - "identified": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string", "minLength": 1 } }, "additionalProperties": true }, - "profile": { "type": "object", "required": ["description", "tools", "repos", "services"], "properties": { "description": { "type": "string" }, "tools": { "type": "array", "items": { "type": "string" } }, "repos": { "type": "array", "items": { "type": "string" } }, "services": { "type": "array", "items": { "type": "string" } } }, "additionalProperties": false } + "identified": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" } }, "additionalProperties": true }, + "selection": { "type": "object", "required": ["required", "optional"], "properties": { "required": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, "optional": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } } }, "additionalProperties": false }, + "profile": { "type": "object", "required": ["description", "tools", "repos", "services", "clients", "skills", "workspaces"], "properties": { "description": { "type": "string", "minLength": 1 }, "tools": { "$ref": "#/$defs/selection" }, "repos": { "$ref": "#/$defs/selection" }, "services": { "$ref": "#/$defs/selection" }, "clients": { "$ref": "#/$defs/selection" }, "skills": { "$ref": "#/$defs/selection" }, "workspaces": { "$ref": "#/$defs/selection" } }, "additionalProperties": false }, + "policy": { "type": "object", "required": ["allowedInstallerKinds", "allowedCommands", "allowedRepositoryPrefixes", "allowedConfigTargets"], "properties": { "allowedInstallerKinds": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["winget", "scoop", "npm", "manual"] } }, "allowedCommands": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, "allowedRepositoryPrefixes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^https://.+" } }, "allowedConfigTargets": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[^\\\\/:*?\"<>|]+$" } } }, "additionalProperties": false }, + "installer": { "type": "object", "required": ["kind"], "properties": { "kind": { "enum": ["winget", "scoop", "npm", "manual"] }, "id": { "type": "string", "minLength": 1 }, "hint": { "type": "string", "minLength": 1 } }, "additionalProperties": false }, + "tool": { "type": "object", "required": ["id", "displayName", "command", "versionArgs", "versionPattern", "minimumVersion", "installers"], "properties": { "id": { "type": "string", "minLength": 1 }, "displayName": { "type": "string", "minLength": 1 }, "command": { "type": "string", "minLength": 1 }, "versionArgs": { "type": "array", "items": { "type": "string" } }, "versionPattern": { "type": "string", "minLength": 1 }, "minimumVersion": { "type": "string", "minLength": 1 }, "installers": { "type": "array", "items": { "$ref": "#/$defs/installer" } } }, "additionalProperties": false }, + "repository": { "type": "object", "required": ["id", "url", "defaultBranch"], "properties": { "id": { "type": "string", "minLength": 1 }, "url": { "type": "string", "pattern": "^https://.+\\.git$" }, "defaultBranch": { "type": "string", "minLength": 1 } }, "additionalProperties": false }, + "client": { "type": "object", "required": ["id", "fileName"], "properties": { "id": { "type": "string", "minLength": 1 }, "fileName": { "type": "string", "pattern": "^[^\\\\/:*?\"<>|]+$" } }, "additionalProperties": false }, + "workspace": { "type": "object", "required": ["id", "relativePath"], "properties": { "id": { "type": "string", "minLength": 1 }, "relativePath": { "type": "string", "minLength": 1 } }, "additionalProperties": false } } } - diff --git a/scripts/Cas.Workstation.psm1 b/scripts/Cas.Workstation.psm1 index f1382f1..f289f9a 100644 --- a/scripts/Cas.Workstation.psm1 +++ b/scripts/Cas.Workstation.psm1 @@ -13,7 +13,226 @@ function Get-CasManifest { [string]$Path = (Get-CasManifestPath) ) - Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Manifest was not found: $Path" + } + + try { + $manifest = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + catch { + throw "Manifest '$Path' is not valid JSON: $($_.Exception.Message)" + } + + Assert-CasManifest -Manifest $manifest + $manifest +} + +function Get-CasPropertyNames { + param([object]$InputObject) + + @($InputObject.PSObject.Properties | ForEach-Object { $_.Name }) +} + +function Assert-CasUniqueIds { + param([string]$Category, [object[]]$Items) + + $duplicates = @($Items | ForEach-Object id | Group-Object | Where-Object Count -gt 1 | ForEach-Object Name) + if ($duplicates.Count -gt 0) { + throw "Manifest category '$Category' contains duplicate id(s): $($duplicates -join ', ')." + } +} + +function Assert-CasManifest { + param([Parameter(Mandatory = $true)][pscustomobject]$Manifest) + + $requiredProperties = @("manifestVersion", "bundleId", "defaults", "policy", "profiles", "paths", "tools", "repos", "services", "clients", "skills", "workspaces", "sharedMcpServer") + foreach ($property in $requiredProperties) { + if (-not $Manifest.PSObject.Properties[$property]) { + throw "Manifest is missing required property '$property'." + } + } + + $categories = @("tools", "repos", "services", "clients", "skills", "workspaces") + foreach ($category in $categories) { + Assert-CasUniqueIds -Category $category -Items @($Manifest.$category) + } + + $allowedCommands = @($Manifest.policy.allowedCommands) + foreach ($tool in @($Manifest.tools)) { + if ($allowedCommands -notcontains $tool.command) { + throw "Tool '$($tool.id)' uses unallowlisted command '$($tool.command)'." + } + foreach ($installer in @($tool.installers)) { + if (@($Manifest.policy.allowedInstallerKinds) -notcontains $installer.kind) { + throw "Tool '$($tool.id)' uses unallowlisted installer kind '$($installer.kind)'." + } + if ($installer.kind -ne "manual" -and (-not $installer.id -or $installer.id -notmatch '^[A-Za-z0-9@][A-Za-z0-9@/._-]+$')) { + throw "Tool '$($tool.id)' has an invalid package identity." + } + } + } + + foreach ($repo in @($Manifest.repos)) { + $trusted = @($Manifest.policy.allowedRepositoryPrefixes | Where-Object { $repo.url.StartsWith($_, [StringComparison]::OrdinalIgnoreCase) }) + if ($trusted.Count -eq 0) { + throw "Repository '$($repo.id)' uses unallowlisted URL '$($repo.url)'." + } + } + + foreach ($client in @($Manifest.clients)) { + if (@($Manifest.policy.allowedConfigTargets) -notcontains $client.fileName) { + throw "Client '$($client.id)' uses unallowlisted configuration target '$($client.fileName)'." + } + } + + if ($allowedCommands -notcontains $Manifest.sharedMcpServer.command) { + throw "Shared MCP server uses unallowlisted command '$($Manifest.sharedMcpServer.command)'." + } + + foreach ($profileName in Get-CasPropertyNames -InputObject $Manifest.profiles) { + $profile = $Manifest.profiles.PSObject.Properties[$profileName].Value + foreach ($category in $categories) { + if (-not $profile.PSObject.Properties[$category]) { + throw "Profile '$profileName' is missing category '$category'." + } + $selection = $profile.$category + foreach ($level in @("required", "optional")) { + if (-not $selection.PSObject.Properties[$level]) { + throw "Profile '$profileName' category '$category' is missing '$level'." + } + } + $overlap = @($selection.required | Where-Object { @($selection.optional) -contains $_ }) + if ($overlap.Count -gt 0) { + throw "Profile '$profileName' category '$category' repeats id(s) as required and optional: $($overlap -join ', ')." + } + $knownIds = @($Manifest.$category | ForEach-Object id) + foreach ($id in @($selection.required) + @($selection.optional)) { + if ($knownIds -notcontains $id) { + throw "Profile '$profileName' references unknown $category id '$id'." + } + } + } + } + + if (-not $Manifest.profiles.PSObject.Properties[$Manifest.defaults.profile]) { + throw "Default profile '$($Manifest.defaults.profile)' does not exist." + } +} + +function ConvertTo-CasCanonicalValue { + param([AllowNull()][object]$Value) + + if ($null -eq $Value) { + return $null + } + if ($Value -is [string] -or $Value -is [ValueType]) { + return $Value + } + if ($Value -is [System.Collections.IDictionary]) { + $ordered = [ordered]@{} + foreach ($key in @($Value.Keys | Sort-Object)) { + $ordered[$key] = ConvertTo-CasCanonicalValue -Value $Value[$key] + } + return $ordered + } + if ($Value -is [System.Collections.IEnumerable]) { + return @($Value | ForEach-Object { ConvertTo-CasCanonicalValue -Value $_ }) + } + + $result = [ordered]@{} + foreach ($property in @($Value.PSObject.Properties | Sort-Object Name)) { + $result[$property.Name] = ConvertTo-CasCanonicalValue -Value $property.Value + } + $result +} + +function ConvertTo-CasCanonicalJson { + param([Parameter(Mandatory = $true)][object]$InputObject) + + ConvertTo-CasCanonicalValue -Value $InputObject | ConvertTo-Json -Depth 30 -Compress +} + +function Get-CasSha256 { + param([Parameter(Mandatory = $true)][string]$Value) + + $sha = [Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + "sha256:$([BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant())" + } + finally { + $sha.Dispose() + } +} + +function Resolve-CasDesiredState { + param( + [string]$Profile = "full", + [pscustomobject]$Manifest = (Get-CasManifest) + ) + + Assert-CasManifest -Manifest $Manifest + $profileDefinition = Get-CasProfile -Name $Profile -Manifest $Manifest + $resolved = [ordered]@{ + schemaVersion = "1.0.0" + bundleId = $Manifest.bundleId + manifestVersion = $Manifest.manifestVersion + profile = $Profile + resources = @() + } + + foreach ($category in @("tools", "repos", "services", "clients", "skills", "workspaces")) { + $catalog = @($Manifest.$category) + foreach ($required in @($true, $false)) { + $level = if ($required) { "required" } else { "optional" } + foreach ($id in @($profileDefinition.$category.$level | Sort-Object)) { + $definition = $catalog | Where-Object id -eq $id | Select-Object -First 1 + $resolved.resources += [ordered]@{ + category = $category + id = $id + required = $required + definition = ConvertTo-CasCanonicalValue -Value $definition + } + } + } + } + + $canonical = ConvertTo-CasCanonicalJson -InputObject $resolved + [pscustomobject]@{ + desiredState = $resolved + canonicalJson = $canonical + digest = Get-CasSha256 -Value $canonical + } +} + +function Get-CasCompatibilityReport { + param( + [string]$Profile = "full", + [pscustomobject]$Manifest = (Get-CasManifest), + [switch]$IncludeToolInventory + ) + + Assert-CasManifest -Manifest $Manifest + $checks = New-Object System.Collections.Generic.List[object] + $isWindows = $env:OS -eq "Windows_NT" -or [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT + $checks.Add([pscustomobject]@{ id = "host-os"; required = $true; status = if ($isWindows) { "supported" } else { "unsupported" }; actual = [Environment]::OSVersion.Platform.ToString(); message = "Windows 11 is the supported v1 host." }) + $checks.Add([pscustomobject]@{ id = "powershell"; required = $true; status = if ($PSVersionTable.PSVersion -ge [version]"5.1") { "supported" } else { "unsupported" }; actual = $PSVersionTable.PSVersion.ToString(); message = "PowerShell 5.1 or later is required." }) + $architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + $checks.Add([pscustomobject]@{ id = "architecture"; required = $true; status = if ($architecture -in @("X64", "Arm64")) { "supported" } else { "unsupported" }; actual = $architecture; message = "X64 and Arm64 are supported." }) + + if ($IncludeToolInventory) { + foreach ($tool in Get-CasProfileToolDefinitions -Profile $Profile -Manifest $Manifest) { + $status = Get-CasToolStatus -Tool $tool + $checks.Add([pscustomobject]@{ id = "tool:$($tool.id)"; required = $true; status = if ($status.status -eq "installed") { "supported" } elseif ($status.status -eq "missing") { "unknown" } else { "unsupported" }; actual = $status.installedVersion; message = $status.message }) + } + } + + [pscustomobject]@{ + profile = $Profile + compatible = @($checks | Where-Object { $_.required -and $_.status -ne "supported" }).Count -eq 0 + checks = $checks.ToArray() + } } function Get-CasDefaultRootPath { @@ -78,7 +297,8 @@ function Get-CasProfileToolDefinitions { [pscustomobject]$Manifest = (Get-CasManifest) ) - $toolIds = @((Get-CasProfile -Name $Profile -Manifest $Manifest).tools) + $profileDefinition = Get-CasProfile -Name $Profile -Manifest $Manifest + $toolIds = @($profileDefinition.tools.required) + @($profileDefinition.tools.optional) foreach ($toolId in $toolIds) { $Manifest.tools | Where-Object { $_.id -eq $toolId } } @@ -90,7 +310,8 @@ function Get-CasProfileRepos { [pscustomobject]$Manifest = (Get-CasManifest) ) - $repoIds = @((Get-CasProfile -Name $Profile -Manifest $Manifest).repos) + $profileDefinition = Get-CasProfile -Name $Profile -Manifest $Manifest + $repoIds = @($profileDefinition.repos.required) + @($profileDefinition.repos.optional) foreach ($repoId in $repoIds) { $Manifest.repos | Where-Object { $_.id -eq $repoId } } @@ -281,7 +502,8 @@ function Get-CasServiceStatuses { ) $statuses = @() - $profileServices = @((Get-CasProfile -Name $Profile).services) + $profileDefinition = Get-CasProfile -Name $Profile + $profileServices = @($profileDefinition.services.required) + @($profileDefinition.services.optional) foreach ($service in $profileServices) { switch ($service) { "docker-daemon" { $statuses += Test-CasDockerDaemon } diff --git a/stack.manifest.json b/stack.manifest.json index c9b899e..ed64d71 100644 --- a/stack.manifest.json +++ b/stack.manifest.json @@ -7,62 +7,30 @@ "configPath": "C:\\Users\\KimHarjamaki\\.cas", "profile": "full" }, + "policy": { + "allowedInstallerKinds": ["winget", "scoop", "npm", "manual"], + "allowedCommands": ["git", "gh", "node", "npm", "python", "uv", "dotnet", "docker", "az", "wsl.exe", "codex", "claude", "gemini"], + "allowedRepositoryPrefixes": ["https://github.com/Coding-Autopilot-System/"], + "allowedConfigTargets": ["codex.mcp.json", "claude.settings.fragment.json", "gemini.mcp.json"] + }, "profiles": { "core": { "description": "Base workstation for AI-native coding.", - "tools": [ - "git", - "gh", - "node", - "npm", - "python", - "uv", - "dotnet", - "docker", - "az", - "wsl", - "codex", - "claude", - "gemini" - ], - "repos": [ - "Promptimprover", - "autogen", - "gsd-orchestrator" - ], - "services": [ - "docker-daemon", - "gh-auth" - ] + "tools": { "required": ["git", "gh", "node", "npm", "python", "uv", "dotnet", "docker", "az", "wsl", "codex", "claude", "gemini"], "optional": [] }, + "repos": { "required": ["Promptimprover", "autogen", "gsd-orchestrator"], "optional": [] }, + "services": { "required": ["docker-daemon", "gh-auth"], "optional": [] }, + "clients": { "required": ["codex", "claude", "gemini"], "optional": [] }, + "skills": { "required": [], "optional": ["prompt-refiner"] }, + "workspaces": { "required": [], "optional": ["cas-default"] } }, "full": { "description": "Full workstation with org automation components.", - "tools": [ - "git", - "gh", - "node", - "npm", - "python", - "uv", - "dotnet", - "docker", - "az", - "wsl", - "codex", - "claude", - "gemini" - ], - "repos": [ - "Promptimprover", - "autogen", - "gsd-orchestrator", - "autopilot-core", - "ci-autopilot" - ], - "services": [ - "docker-daemon", - "gh-auth" - ] + "tools": { "required": ["git", "gh", "node", "npm", "python", "uv", "dotnet", "docker", "az", "wsl", "codex", "claude", "gemini"], "optional": [] }, + "repos": { "required": ["Promptimprover", "autogen", "gsd-orchestrator", "autopilot-core", "ci-autopilot"], "optional": [] }, + "services": { "required": ["docker-daemon", "gh-auth"], "optional": [] }, + "clients": { "required": ["codex", "claude", "gemini"], "optional": [] }, + "skills": { "required": ["prompt-refiner"], "optional": [] }, + "workspaces": { "required": ["cas-default"], "optional": [] } } }, "paths": { @@ -333,6 +301,10 @@ "defaultBranch": "main" } ], + "services": [ + { "id": "docker-daemon" }, + { "id": "gh-auth" } + ], "clients": [ { "id": "codex", @@ -347,6 +319,18 @@ "fileName": "gemini.mcp.json" } ], + "skills": [ + { + "id": "prompt-refiner", + "repo": "Promptimprover" + } + ], + "workspaces": [ + { + "id": "cas-default", + "relativePath": "workspaces\\default" + } + ], "sharedMcpServer": { "name": "prompt-refiner", "transport": "stdio", diff --git a/tests/Manifest.Tests.ps1 b/tests/Manifest.Tests.ps1 new file mode 100644 index 0000000..08583b7 --- /dev/null +++ b/tests/Manifest.Tests.ps1 @@ -0,0 +1,58 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:repoRoot "scripts\Cas.Workstation.psm1") -Force + $script:manifestPath = Join-Path $script:repoRoot "stack.manifest.json" +} + +Describe "CAS manifest validation and resolution" { + It "loads the repository manifest only after semantic validation" { + $manifest = Get-CasManifest -Path $script:manifestPath + $manifest.bundleId | Should -Be "cas-workstation" + } + + It "rejects an unallowlisted command before invoking it" { + $manifest = Get-Content $script:manifestPath -Raw | ConvertFrom-Json + $manifest.tools[0].command = "malicious.exe" + + { Assert-CasManifest -Manifest $manifest } | Should -Throw "*unallowlisted command*" + } + + It "rejects unknown profile references" { + $manifest = Get-Content $script:manifestPath -Raw | ConvertFrom-Json + $manifest.profiles.core.tools.required += "unknown-tool" + + { Assert-CasManifest -Manifest $manifest } | Should -Throw "*unknown tools id*" + } + + It "rejects untrusted repository origins" { + $manifest = Get-Content $script:manifestPath -Raw | ConvertFrom-Json + $manifest.repos[0].url = "https://example.invalid/repository.git" + + { Assert-CasManifest -Manifest $manifest } | Should -Throw "*unallowlisted URL*" + } + + It "resolves every declarative category with explicit requirement level" { + $resolved = Resolve-CasDesiredState -Profile core + @($resolved.desiredState.resources.category | Sort-Object -Unique) | Should -Be @("clients", "repos", "services", "skills", "tools", "workspaces") + @($resolved.desiredState.resources | Where-Object required).Count | Should -BeGreaterThan 0 + @($resolved.desiredState.resources | Where-Object { -not $_.required }).Count | Should -BeGreaterThan 0 + } + + It "produces a deterministic canonical desired-state digest" { + $first = Resolve-CasDesiredState -Profile full + $second = Resolve-CasDesiredState -Profile full + + $first.canonicalJson | Should -BeExactly $second.canonicalJson + $first.digest | Should -BeExactly $second.digest + $first.digest | Should -Match "^sha256:[a-f0-9]{64}$" + } + + It "returns structured fail-closed compatibility evidence" { + $report = Get-CasCompatibilityReport -Profile core + + $report.checks.id | Should -Contain "host-os" + $report.checks.id | Should -Contain "powershell" + $report.checks.id | Should -Contain "architecture" + ($report.compatible -eq (@($report.checks | Where-Object { $_.required -and $_.status -ne "supported" }).Count -eq 0)) | Should -BeTrue + } +} diff --git a/tests/fixtures/contracts/manifest.valid.json b/tests/fixtures/contracts/manifest.valid.json index fb538df..ba52176 100644 --- a/tests/fixtures/contracts/manifest.valid.json +++ b/tests/fixtures/contracts/manifest.valid.json @@ -1 +1 @@ -{"manifestVersion":"1.0.0","bundleName":"CAS Workstation","bundleId":"cas-workstation","defaults":{"rootPath":"C:\\CAS","configPath":"C:\\Users\\developer\\.cas","profile":"core"},"profiles":{"core":{"description":"Core","tools":[],"repos":[],"services":[]}},"paths":{"state":"state"},"tools":[],"repos":[],"clients":[],"sharedMcpServer":{"name":"refiner","transport":"stdio","command":"node","args":[]}} +{"manifestVersion":"1.0.0","bundleName":"CAS Workstation","bundleId":"cas-workstation","defaults":{"rootPath":"C:\\CAS","configPath":"C:\\Users\\developer\\.cas","profile":"core"},"policy":{"allowedInstallerKinds":["winget"],"allowedCommands":["node"],"allowedRepositoryPrefixes":["https://github.com/example/"],"allowedConfigTargets":["client.json"]},"profiles":{"core":{"description":"Core","tools":{"required":[],"optional":[]},"repos":{"required":[],"optional":[]},"services":{"required":[],"optional":[]},"clients":{"required":[],"optional":[]},"skills":{"required":[],"optional":[]},"workspaces":{"required":[],"optional":[]}}},"paths":{"state":"state"},"tools":[],"repos":[],"services":[],"clients":[],"skills":[],"workspaces":[],"sharedMcpServer":{"name":"refiner","transport":"stdio","command":"node","args":[]}} From 0525d4a086a4b0a10fa7692624d861985a9be3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 14:09:57 +0300 Subject: [PATCH 3/4] feat(02-02): enforce ownership and path safety --- .../02-02-SUMMARY.md | 34 +++ schemas/managed-state.schema.json | 23 +- scripts/Cas.Workstation.psm1 | 233 ++++++++++++++++++ tests/Safety.Tests.ps1 | 82 ++++++ .../contracts/managed-state.valid.json | 2 +- 5 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-SUMMARY.md create mode 100644 tests/Safety.Tests.ps1 diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-SUMMARY.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-SUMMARY.md new file mode 100644 index 0000000..a2e154e --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-02-SUMMARY.md @@ -0,0 +1,34 @@ +--- +phase: 02-manifest-inventory-and-safety-boundaries +plan: 02 +subsystem: safety +tags: [powershell, filesystem, ownership, atomic-write] +requires: [02-01] +provides: [canonical-path-policy, ownership-ledger, atomic-json-write] +affects: [02-03, phase-3, phase-4] +key-files: + created: [tests/Safety.Tests.ps1] + modified: [schemas/managed-state.schema.json, scripts/Cas.Workstation.psm1] +key-decisions: + - "Every mutation target is revalidated against approved roots and reparse-point policy." + - "Created ownership requires explicit evidence that the resource did not previously exist." +requirements-completed: [SAFE-01, SAFE-02, SAFE-04, SAFE-05] +completed: 2026-06-11 +--- + +# Phase 2 Plan 2: Safety Boundary Summary + +Canonical filesystem policy, explicit ownership evidence, and backup-aware atomic managed-state writes now constrain later mutation paths. + +## Verification + +- Safety Pester tests: 8/8 passed. +- Full Pester regression: 26/26 passed. +- Managed-state schema fixtures: passed. +- PSScriptAnalyzer: zero error findings. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Self-Check: PASSED diff --git a/schemas/managed-state.schema.json b/schemas/managed-state.schema.json index 20d4989..a92dafd 100644 --- a/schemas/managed-state.schema.json +++ b/schemas/managed-state.schema.json @@ -10,8 +10,27 @@ "bundleId": { "type": "string", "minLength": 1 }, "profile": { "type": "string", "minLength": 1 }, "desiredStateDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, - "resources": { "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "ownership", "target"], "properties": { "id": { "type": "string" }, "kind": { "type": "string" }, "ownership": { "enum": ["created", "modified", "observed"] }, "target": { "type": "string" } } } }, + "resources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "ownership", "target", "wasPresentBefore"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "kind": { "enum": ["directory", "file", "repository", "configuration", "tool"] }, + "ownership": { "enum": ["created", "modified", "observed"] }, + "target": { "type": "string", "minLength": 1 }, + "wasPresentBefore": { "type": "boolean" }, + "backupTarget": { "type": ["string", "null"] }, + "contentDigest": { "type": ["string", "null"], "pattern": "^sha256:[a-f0-9]{64}$" } + }, + "allOf": [ + { "if": { "properties": { "ownership": { "const": "created" } } }, "then": { "properties": { "wasPresentBefore": { "const": false } } } }, + { "if": { "properties": { "ownership": { "const": "modified" } } }, "then": { "required": ["backupTarget"], "properties": { "wasPresentBefore": { "const": true }, "backupTarget": { "type": "string", "minLength": 1 } } } } + ] + } + }, "operations": { "type": "array", "items": { "type": "string" } } } } - diff --git a/scripts/Cas.Workstation.psm1 b/scripts/Cas.Workstation.psm1 index f289f9a..692ac11 100644 --- a/scripts/Cas.Workstation.psm1 +++ b/scripts/Cas.Workstation.psm1 @@ -235,6 +235,239 @@ function Get-CasCompatibilityReport { } } +function Resolve-CasCanonicalPath { + param([Parameter(Mandatory = $true)][string]$Path) + + try { + $fullPath = [IO.Path]::GetFullPath($Path) + } + catch { + throw "Path '$Path' cannot be canonicalized: $($_.Exception.Message)" + } + + $root = [IO.Path]::GetPathRoot($fullPath) + if ($fullPath.Length -gt $root.Length) { + $fullPath = $fullPath.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + } + $fullPath +} + +function Get-CasForbiddenPaths { + $paths = New-Object System.Collections.Generic.List[string] + foreach ($candidate in @($env:USERPROFILE, $env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData)) { + if ($candidate) { + $paths.Add((Resolve-CasCanonicalPath -Path $candidate)) + } + } + foreach ($drive in [IO.DriveInfo]::GetDrives()) { + $paths.Add((Resolve-CasCanonicalPath -Path $drive.RootDirectory.FullName)) + } + $paths.ToArray() | Sort-Object -Unique +} + +function Test-CasPathHasReparsePoint { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$StopAt + ) + + $current = Resolve-CasCanonicalPath -Path $Path + $stop = Resolve-CasCanonicalPath -Path $StopAt + while ($current.StartsWith($stop, [StringComparison]::OrdinalIgnoreCase)) { + if (Test-Path -LiteralPath $current) { + $item = Get-Item -LiteralPath $current -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + return $true + } + } + if ($current.Equals($stop, [StringComparison]::OrdinalIgnoreCase)) { + break + } + $parent = Split-Path -Parent $current + if (-not $parent -or $parent -eq $current) { + break + } + $current = $parent + } + $false +} + +function Assert-CasSafePath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string[]]$ApprovedRoots, + [switch]$AllowBoundary + ) + + $canonical = Resolve-CasCanonicalPath -Path $Path + $approved = $null + foreach ($rootPath in $ApprovedRoots) { + $root = Resolve-CasCanonicalPath -Path $rootPath + $prefix = "$root$([IO.Path]::DirectorySeparatorChar)" + if ($canonical.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -or ($AllowBoundary -and $canonical.Equals($root, [StringComparison]::OrdinalIgnoreCase))) { + $approved = $root + break + } + } + if (-not $approved) { + throw "Path '$canonical' is outside approved CAS boundaries." + } + + foreach ($forbidden in Get-CasForbiddenPaths) { + if ($canonical.Equals($forbidden, [StringComparison]::OrdinalIgnoreCase)) { + throw "Path '$canonical' is a forbidden root." + } + } + + foreach ($systemRoot in @($env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData)) { + if ($systemRoot) { + $forbidden = Resolve-CasCanonicalPath -Path $systemRoot + if ($canonical.StartsWith("$forbidden$([IO.Path]::DirectorySeparatorChar)", [StringComparison]::OrdinalIgnoreCase)) { + throw "Path '$canonical' is inside a forbidden system directory." + } + } + } + + if (Test-CasPathHasReparsePoint -Path $canonical -StopAt $approved) { + throw "Path '$canonical' or an existing ancestor is a reparse point." + } + $canonical +} + +function New-CasManagedState { + param( + [Parameter(Mandatory = $true)][string]$BundleId, + [Parameter(Mandatory = $true)][string]$Profile, + [Parameter(Mandatory = $true)][string]$DesiredStateDigest + ) + + [pscustomobject]@{ + schemaVersion = "1.0.0" + bundleId = $BundleId + profile = $Profile + desiredStateDigest = $DesiredStateDigest + resources = @() + operations = @() + } +} + +function Add-CasManagedResource { + param( + [Parameter(Mandatory = $true)][pscustomobject]$State, + [Parameter(Mandatory = $true)][string]$Id, + [Parameter(Mandatory = $true)][ValidateSet("directory", "file", "repository", "configuration", "tool")][string]$Kind, + [Parameter(Mandatory = $true)][ValidateSet("created", "modified", "observed")][string]$Ownership, + [Parameter(Mandatory = $true)][string]$Target, + [Parameter(Mandatory = $true)][bool]$WasPresentBefore, + [string]$BackupTarget, + [string]$ContentDigest + ) + + if ($Ownership -eq "created" -and $WasPresentBefore) { + throw "Resource '$Id' cannot be owned as created because it existed before CAS management." + } + if ($Ownership -eq "modified" -and (-not $WasPresentBefore -or -not $BackupTarget)) { + throw "Modified resource '$Id' requires pre-existing evidence and a backup target." + } + if (@($State.resources | Where-Object id -eq $Id).Count -gt 0) { + throw "Managed resource id '$Id' already exists." + } + + $State.resources += [pscustomobject]@{ + id = $Id + kind = $Kind + ownership = $Ownership + target = Resolve-CasCanonicalPath -Path $Target + wasPresentBefore = $WasPresentBefore + backupTarget = if ($BackupTarget) { Resolve-CasCanonicalPath -Path $BackupTarget } else { $null } + contentDigest = if ($ContentDigest) { $ContentDigest } else { $null } + } + $State +} + +function Assert-CasManagedState { + param([Parameter(Mandatory = $true)][pscustomobject]$State) + + if ($State.schemaVersion -ne "1.0.0" -or $State.desiredStateDigest -notmatch '^sha256:[a-f0-9]{64}$') { + throw "Managed state has an invalid schema version or desired-state digest." + } + if (@($State.resources | ForEach-Object id | Group-Object | Where-Object Count -gt 1).Count -gt 0) { + throw "Managed state contains duplicate resource ids." + } + foreach ($resource in @($State.resources)) { + if ($resource.ownership -eq "created" -and $resource.wasPresentBefore) { + throw "Created resource '$($resource.id)' has conflicting pre-existing evidence." + } + if ($resource.ownership -eq "modified" -and (-not $resource.wasPresentBefore -or -not $resource.backupTarget)) { + throw "Modified resource '$($resource.id)' is missing backup evidence." + } + } +} + +function Write-CasAtomicJson { + param( + [Parameter(Mandatory = $true)][object]$InputObject, + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string[]]$ApprovedRoots, + [switch]$AllowBoundary + ) + + $target = Assert-CasSafePath -Path $Path -ApprovedRoots $ApprovedRoots -AllowBoundary:$AllowBoundary + $directory = Split-Path -Parent $target + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + throw "Atomic write parent directory does not exist: $directory" + } + + $json = $InputObject | ConvertTo-Json -Depth 30 + $null = $json | ConvertFrom-Json + $temp = Join-Path $directory ".$([IO.Path]::GetFileName($target)).$([Guid]::NewGuid().ToString('N')).tmp" + $backup = $null + try { + [IO.File]::WriteAllText($temp, $json, (New-Object Text.UTF8Encoding($false))) + $null = Get-Content -LiteralPath $temp -Raw | ConvertFrom-Json + if (Test-Path -LiteralPath $target -PathType Leaf) { + $backup = "$target.backup.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff'))" + [IO.File]::Replace($temp, $target, $backup) + } + else { + [IO.File]::Move($temp, $target) + } + } + finally { + if (Test-Path -LiteralPath $temp) { + Remove-Item -LiteralPath $temp -Force + } + } + $backup +} + +function Write-CasManagedState { + param( + [Parameter(Mandatory = $true)][pscustomobject]$State, + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string[]]$ApprovedRoots + ) + + Assert-CasManagedState -State $State + Write-CasAtomicJson -InputObject $State -Path $Path -ApprovedRoots $ApprovedRoots +} + +function Read-CasManagedState { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Managed state was not found: $Path" + } + try { + $state = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + catch { + throw "Managed state '$Path' is not valid JSON: $($_.Exception.Message)" + } + Assert-CasManagedState -State $state + $state +} + function Get-CasDefaultRootPath { param( [pscustomobject]$Manifest = (Get-CasManifest) diff --git a/tests/Safety.Tests.ps1 b/tests/Safety.Tests.ps1 new file mode 100644 index 0000000..03c92ac --- /dev/null +++ b/tests/Safety.Tests.ps1 @@ -0,0 +1,82 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:repoRoot "scripts\Cas.Workstation.psm1") -Force +} + +Describe "CAS filesystem boundary policy" { + It "accepts a canonical child below an approved boundary" { + $root = Join-Path $TestDrive "cas" + New-Item -ItemType Directory -Path $root -Force | Out-Null + $target = Join-Path $root "state\managed-state.json" + + Assert-CasSafePath -Path $target -ApprovedRoots $root | Should -Be ([IO.Path]::GetFullPath($target)) + } + + It "rejects targets outside approved boundaries" { + $root = Join-Path $TestDrive "cas" + $outside = Join-Path $TestDrive "unrelated\file.json" + New-Item -ItemType Directory -Path $root -Force | Out-Null + + { Assert-CasSafePath -Path $outside -ApprovedRoots $root } | Should -Throw "*outside approved CAS boundaries*" + } + + It "rejects an approved boundary itself unless explicitly allowed" { + $root = Join-Path $TestDrive "cas" + New-Item -ItemType Directory -Path $root -Force | Out-Null + + { Assert-CasSafePath -Path $root -ApprovedRoots $root } | Should -Throw + Assert-CasSafePath -Path $root -ApprovedRoots $root -AllowBoundary | Should -Be ([IO.Path]::GetFullPath($root)) + } + + It "rejects a reparse-point target or ancestor" { + InModuleScope Cas.Workstation -Parameters @{ TestRoot = (Join-Path $TestDrive "cas") } { + param($TestRoot) + New-Item -ItemType Directory -Path $TestRoot -Force | Out-Null + Mock Test-CasPathHasReparsePoint { $true } + + { Assert-CasSafePath -Path (Join-Path $TestRoot "unsafe") -ApprovedRoots $TestRoot } | Should -Throw "*reparse point*" + } + } +} + +Describe "CAS ownership ledger and atomic writes" { + It "never claims a pre-existing resource as created" { + $state = New-CasManagedState -BundleId cas-workstation -Profile core -DesiredStateDigest "sha256:$('a' * 64)" + + { Add-CasManagedResource -State $state -Id existing -Kind file -Ownership created -Target (Join-Path $TestDrive "existing") -WasPresentBefore $true } | Should -Throw "*existed before*" + } + + It "requires backup evidence for modified resources" { + $state = New-CasManagedState -BundleId cas-workstation -Profile core -DesiredStateDigest "sha256:$('a' * 64)" + + { Add-CasManagedResource -State $state -Id modified -Kind configuration -Ownership modified -Target (Join-Path $TestDrive "config.json") -WasPresentBefore $true } | Should -Throw "*backup target*" + } + + It "writes and validates managed state atomically" { + $root = Join-Path $TestDrive "cas" + $stateRoot = Join-Path $root "state" + New-Item -ItemType Directory -Path $stateRoot -Force | Out-Null + $path = Join-Path $stateRoot "managed-state.json" + $state = New-CasManagedState -BundleId cas-workstation -Profile core -DesiredStateDigest "sha256:$('a' * 64)" + $null = Add-CasManagedResource -State $state -Id created -Kind directory -Ownership created -Target (Join-Path $root "repos") -WasPresentBefore $false + + Write-CasManagedState -State $state -Path $path -ApprovedRoots $root + $loaded = Read-CasManagedState -Path $path + + $loaded.resources[0].ownership | Should -Be "created" + Get-ChildItem $stateRoot -Filter "*.tmp" | Should -BeNullOrEmpty + } + + It "backs up an existing valid target before atomic replacement" { + $root = Join-Path $TestDrive "cas" + New-Item -ItemType Directory -Path $root -Force | Out-Null + $path = Join-Path $root "state.json" + '{"old":true}' | Set-Content -LiteralPath $path + + $backup = Write-CasAtomicJson -InputObject ([pscustomobject]@{ new = $true }) -Path $path -ApprovedRoots $root + + Test-Path -LiteralPath $backup | Should -BeTrue + (Get-Content $backup -Raw | ConvertFrom-Json).old | Should -BeTrue + (Get-Content $path -Raw | ConvertFrom-Json).new | Should -BeTrue + } +} diff --git a/tests/fixtures/contracts/managed-state.valid.json b/tests/fixtures/contracts/managed-state.valid.json index 7a2ee01..9acb26e 100644 --- a/tests/fixtures/contracts/managed-state.valid.json +++ b/tests/fixtures/contracts/managed-state.valid.json @@ -1 +1 @@ -{"schemaVersion":"1.0.0","bundleId":"cas-workstation","profile":"core","desiredStateDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","resources":[{"id":"repo-1","kind":"repository","ownership":"created","target":"C:\\CAS\\repos\\one"}],"operations":[]} +{"schemaVersion":"1.0.0","bundleId":"cas-workstation","profile":"core","desiredStateDigest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","resources":[{"id":"repo-1","kind":"repository","ownership":"created","target":"C:\\CAS\\repos\\one","wasPresentBefore":false,"backupTarget":null,"contentDigest":null}],"operations":[]} From efe872868e4025fd5aee9d8ba8903791685c37c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Thu, 11 Jun 2026 22:11:32 +0300 Subject: [PATCH 4/4] feat(02-03): enforce ledger-only safe uninstall --- .planning/ROADMAP.md | 2 +- .planning/STATE.md | 24 +-- .../02-03-SUMMARY.md | 36 +++++ .../02-VERIFICATION.md | 30 ++++ Invoke-Quality.ps1 | 6 +- README.md | 39 +++++ docs/traceability.json | 142 +++++++++--------- scripts/Cas.Workstation.psm1 | 129 +++++++++++++++- tests/Uninstall.Tests.ps1 | 92 ++++++++++++ uninstall.ps1 | 23 ++- 10 files changed, 428 insertions(+), 95 deletions(-) create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-SUMMARY.md create mode 100644 .planning/phases/02-manifest-inventory-and-safety-boundaries/02-VERIFICATION.md create mode 100644 tests/Uninstall.Tests.ps1 diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 6212ed6..c1a0580 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -30,7 +30,7 @@ CAS Workstation v1 progresses from a functional seed to a trustworthy desired-st 3. Versioned schemas exist for all planned product contracts, with positive and negative fixtures. 4. Architecture decision and requirement traceability conventions are documented and tested. -### Phase 2: Manifest, Inventory, and Safety Boundaries +### Phase 2: Manifest, Inventory, and Safety Boundaries (Complete: 2026-06-11) **Goal:** CAS can safely resolve desired state, inventory actual state, and prove ownership and path safety before mutation. diff --git a/.planning/STATE.md b/.planning/STATE.md index 6eefc74..4e9f9cc 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,13 +3,13 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: ready_to_plan -last_updated: "2026-06-11T11:01:17.107Z" +last_updated: "2026-06-11T19:11:00.000Z" progress: total_phases: 7 - completed_phases: 1 + completed_phases: 2 total_plans: 6 - completed_plans: 3 - percent: 14 + completed_plans: 6 + percent: 29 --- # Project State @@ -18,22 +18,22 @@ progress: See: `.planning/PROJECT.md` (updated 2026-06-11) -**Core value:** An AI developer can run one safe, repeatable workflow and receive a complete, working workstation without manually discovering or reconciling prerequisites. -**Current focus:** Phase 2 — manifest, inventory, and safety boundaries +**Core value:** An AI developer can run one safe, repeatable workflow and receive a complete, working workstation without manually discovering or reconciling prerequisites. +**Current focus:** Phase 3 - transactional plan and apply engine ## Current Position -Phase: 2 +Phase: 3 Plan: Not started - Project initialization: complete - Research: complete - Requirements: 35 v1 requirements, all mapped - Roadmap: 7 phases -- Completed phase: Phase 1 — Governance and Quality Foundation -- Active phase: Phase 2 — Manifest, Inventory, and Safety Boundaries -- Phase 1 plans: 3/3 complete -- Implementation: Phase 1 verified +- Completed phases: Phase 1 and Phase 2 +- Active phase: Phase 3 - Transactional Plan and Apply Engine +- Phase 2 plans: 3/3 complete +- Implementation: Phase 2 verified ## Workflow @@ -47,7 +47,7 @@ Plan: Not started ## Next Action -Run `$gsd-discuss-phase 2` before planning Manifest, Inventory, and Safety Boundaries. +Run `$gsd-discuss-phase 3` before planning Transactional Plan and Apply Engine. ## Decisions and Risks diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-SUMMARY.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-SUMMARY.md new file mode 100644 index 0000000..2b4eb01 --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-03-SUMMARY.md @@ -0,0 +1,36 @@ +--- +phase: 02-manifest-inventory-and-safety-boundaries +plan: 03 +subsystem: uninstall +tags: [powershell, uninstall, ownership-ledger, safety] +requires: [02-01, 02-02] +provides: [preview-first-uninstall, ledger-only-removal, backup-restore] +affects: [phase-3, phase-5, phase-6] +key-files: + created: [tests/Uninstall.Tests.ps1] + modified: [uninstall.ps1, scripts/Cas.Workstation.psm1, README.md, docs/traceability.json] +key-decisions: + - "Uninstall defaults to preview and requires explicit Apply intent." + - "Only currently safe, ledger-owned resources are actionable." +requirements-completed: [SAFE-03] +completed: 2026-06-11 +--- + +# Phase 2 Plan 3: Ledger-Only Uninstall Summary + +Arbitrary recursive uninstall was replaced with a preview-first workflow that +preserves observed resources, restores modified files from recorded backups, +and removes only safe CAS-created resources. + +## Verification + +- Uninstall Pester tests: 6/6 passed. +- Full quality gate: passed. +- `git diff --check`: passed. + +## Deviations from Plan + +Atomic restore uses a temporary replacement backup because Windows PowerShell +does not accept a null backup path for `System.IO.File.Replace`. + +## Self-Check: PASSED diff --git a/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-VERIFICATION.md b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-VERIFICATION.md new file mode 100644 index 0000000..c418504 --- /dev/null +++ b/.planning/phases/02-manifest-inventory-and-safety-boundaries/02-VERIFICATION.md @@ -0,0 +1,30 @@ +--- +phase: 02-manifest-inventory-and-safety-boundaries +status: passed +verified: 2026-06-11 +requirements: [MAN-01, MAN-02, MAN-03, MAN-04, MAN-05, SAFE-01, SAFE-02, SAFE-03, SAFE-04, SAFE-05] +--- + +# Phase 2 Verification + +## Goal + +CAS safely resolves desired state, inventories compatibility, and proves +ownership and path safety before mutation. + +## Evidence + +- Full quality gate: passed. +- Pester: 32/32 passed. +- Manifest validation rejects unallowlisted and ambiguous operational content. +- Desired-state digest is deterministic. +- Path policy rejects forbidden, escaping, and reparse-point targets. +- Managed-state ledger distinguishes created, modified, and observed resources. +- Uninstall defaults to preview and acts only on safe ledger-owned resources. +- Modified resources restore only from recorded backup evidence. +- Contract fixtures, static analysis, governance, and workflow checks passed. +- `git diff --check`: passed. + +## Verdict + +PASS. Phase 2 goal and all mapped requirements are achieved. diff --git a/Invoke-Quality.ps1 b/Invoke-Quality.ps1 index 3683ebc..763aeac 100644 --- a/Invoke-Quality.ps1 +++ b/Invoke-Quality.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [string]$ArtifactPath = (Join-Path $PSScriptRoot ".artifacts\quality"), + [string]$ArtifactPath, [switch]$SkipTests, [switch]$SkipStaticAnalysis, [switch]$SkipContracts, @@ -8,6 +8,9 @@ param( ) $ErrorActionPreference = "Stop" +if (-not $ArtifactPath) { + $ArtifactPath = Join-Path $PSScriptRoot ".artifacts\quality" +} $results = New-Object System.Collections.Generic.List[object] function Add-QualityResult { @@ -90,4 +93,3 @@ if ($failed.Count -gt 0) { } Write-Output "Quality gate passed. Evidence: $summaryPath" - diff --git a/README.md b/README.md index afbc1b3..af60d28 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,42 @@ It fails closed when a required validator or check is unavailable. .\doctor.ps1 .\start.ps1 ``` + +## Inspect Desired State + +Manifest content is validated before operational use. Profiles resolve into a +normalized desired state with a deterministic SHA-256 digest and structured +compatibility findings: + +```powershell +Import-Module .\scripts\Cas.Workstation.psm1 -Force +$manifest = Get-CasManifest +Resolve-CasDesiredState -Profile core -Manifest $manifest +``` + +Unallowlisted operational identities, ambiguous references, and unsupported +required components fail closed before external process execution. + +## Safety And Managed State + +CAS records explicit ownership under the configured state directory in +`managed-state.json`. Resources are classified as `created`, `modified`, or +`observed`; pre-existing resources cannot be claimed as CAS-created. + +Mutation and removal targets must remain within approved CAS roots. Drive, +profile, system, escaping, and reparse-point paths are rejected. Writes use +validated sibling temporary files and backup evidence. + +## Safe Uninstall + +Uninstall is preview-only by default and acts only on resources proven by the +managed-state ledger: + +```powershell +.\uninstall.ps1 +.\uninstall.ps1 -Apply +``` + +Observed resources are preserved. Modified resources require a recorded backup. +Created directories are removed only when empty; unexpected contents block +removal instead of triggering recursive deletion. diff --git a/docs/traceability.json b/docs/traceability.json index c4ec9ab..60e1451 100644 --- a/docs/traceability.json +++ b/docs/traceability.json @@ -1,4 +1,4 @@ -{ +{ "schemaVersion": "1.0.0", "requirements": [ { @@ -61,142 +61,142 @@ { "id": "MAN-01", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Manifest.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "MAN-02", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Manifest.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "MAN-03", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Manifest.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "MAN-04", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Manifest.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "MAN-05", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Manifest.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "SAFE-01", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Safety.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "SAFE-02", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Safety.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "SAFE-03", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Uninstall.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "SAFE-04", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Safety.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "SAFE-05", "phase": 2, - "status": "pending", + "status": "verified", "adrs": { }, - "tests": { - - }, - "evidence": { - - } + "tests": [ + "tests/Safety.Tests.ps1" + ], + "evidence": [ + ".\\Invoke-Quality.ps1" + ] }, { "id": "OPS-01", diff --git a/scripts/Cas.Workstation.psm1 b/scripts/Cas.Workstation.psm1 index 692ac11..69fdc67 100644 --- a/scripts/Cas.Workstation.psm1 +++ b/scripts/Cas.Workstation.psm1 @@ -459,7 +459,7 @@ function Read-CasManagedState { throw "Managed state was not found: $Path" } try { - $state = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + $state = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Managed state '$Path' is not valid JSON: $($_.Exception.Message)" @@ -468,6 +468,133 @@ function Read-CasManagedState { $state } +function Get-CasManagedStatePath { + param( + [string]$ConfigPath = (Get-CasDefaultConfigPath), + [pscustomobject]$Manifest = (Get-CasManifest) + ) + + Join-Path (Join-Path $ConfigPath $Manifest.paths.state) "managed-state.json" +} + +function Get-CasUninstallPreview { + param( + [Parameter(Mandatory = $true)][string]$StatePath, + [Parameter(Mandatory = $true)][string[]]$ApprovedRoots + ) + + $state = Read-CasManagedState -Path $StatePath + $actions = New-Object System.Collections.Generic.List[object] + foreach ($resource in @($state.resources)) { + if ($resource.ownership -eq "observed") { + $actions.Add([pscustomobject]@{ + id = $resource.id + kind = $resource.kind + ownership = $resource.ownership + target = $resource.target + action = "preserve" + actionable = $false + }) + continue + } + + $target = Assert-CasSafePath -Path $resource.target -ApprovedRoots $ApprovedRoots -AllowBoundary + if ($resource.ownership -eq "modified") { + $backup = Assert-CasSafePath -Path $resource.backupTarget -ApprovedRoots $ApprovedRoots + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { + throw "Backup for modified resource '$($resource.id)' was not found: $backup" + } + $action = "restore-backup" + } + else { + $backup = $null + $action = "remove-created" + } + + $actions.Add([pscustomobject]@{ + id = $resource.id + kind = $resource.kind + ownership = $resource.ownership + target = $target + backupTarget = $backup + action = $action + actionable = $true + }) + } + + [pscustomobject]@{ + schemaVersion = "1.0.0" + bundleId = $state.bundleId + statePath = Resolve-CasCanonicalPath -Path $StatePath + actions = $actions.ToArray() + } +} + +function Restore-CasBackupAtomically { + param( + [Parameter(Mandatory = $true)][string]$BackupPath, + [Parameter(Mandatory = $true)][string]$TargetPath + ) + + $directory = Split-Path -Parent $TargetPath + $temp = Join-Path $directory ".$([IO.Path]::GetFileName($TargetPath)).restore.$([Guid]::NewGuid().ToString('N')).tmp" + $replacedBackup = Join-Path $directory ".$([IO.Path]::GetFileName($TargetPath)).replaced.$([Guid]::NewGuid().ToString('N')).bak" + try { + Copy-Item -LiteralPath $BackupPath -Destination $temp -Force + if (Test-Path -LiteralPath $TargetPath -PathType Leaf) { + [IO.File]::Replace($temp, $TargetPath, $replacedBackup) + } + else { + [IO.File]::Move($temp, $TargetPath) + } + } + finally { + if (Test-Path -LiteralPath $temp) { + Remove-Item -LiteralPath $temp -Force + } + if (Test-Path -LiteralPath $replacedBackup) { + Remove-Item -LiteralPath $replacedBackup -Force + } + } +} + +function Invoke-CasUninstall { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "High")] + param( + [Parameter(Mandatory = $true)][pscustomobject]$Preview, + [Parameter(Mandatory = $true)][string[]]$ApprovedRoots + ) + + $results = New-Object System.Collections.Generic.List[object] + $actions = @($Preview.actions | Where-Object actionable | Sort-Object { $_.target.Length } -Descending) + foreach ($action in $actions) { + $target = Assert-CasSafePath -Path $action.target -ApprovedRoots $ApprovedRoots -AllowBoundary + if (-not $PSCmdlet.ShouldProcess($target, $action.action)) { + continue + } + + if ($action.action -eq "restore-backup") { + $backup = Assert-CasSafePath -Path $action.backupTarget -ApprovedRoots $ApprovedRoots + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { + throw "Backup for '$($action.id)' disappeared before apply." + } + Restore-CasBackupAtomically -BackupPath $backup -TargetPath $target + } + elseif (Test-Path -LiteralPath $target -PathType Leaf) { + Remove-Item -LiteralPath $target -Force + } + elseif (Test-Path -LiteralPath $target -PathType Container) { + if (@(Get-ChildItem -LiteralPath $target -Force).Count -gt 0) { + throw "Created directory '$target' is not empty; refusing recursive removal." + } + Remove-Item -LiteralPath $target -Force + } + + $results.Add([pscustomobject]@{ id = $action.id; target = $target; action = $action.action; status = "applied" }) + } + $results.ToArray() +} + function Get-CasDefaultRootPath { param( [pscustomobject]$Manifest = (Get-CasManifest) diff --git a/tests/Uninstall.Tests.ps1 b/tests/Uninstall.Tests.ps1 new file mode 100644 index 0000000..18fddca --- /dev/null +++ b/tests/Uninstall.Tests.ps1 @@ -0,0 +1,92 @@ +BeforeAll { + $script:repoRoot = Split-Path -Parent $PSScriptRoot + Import-Module (Join-Path $script:repoRoot "scripts\Cas.Workstation.psm1") -Force +} + +Describe "CAS ledger-only uninstall" { + BeforeEach { + $script:root = Join-Path $TestDrive "cas" + $script:config = Join-Path $TestDrive "config" + $script:stateRoot = Join-Path $script:config "state" + New-Item -ItemType Directory -Path $script:root, $script:stateRoot -Force | Out-Null + $script:statePath = Join-Path $script:stateRoot "managed-state.json" + $script:state = New-CasManagedState -BundleId cas-workstation -Profile core -DesiredStateDigest "sha256:$('a' * 64)" + } + + It "previews ledger actions without mutating targets" { + $target = Join-Path $script:root "created.txt" + "owned" | Set-Content -LiteralPath $target + $null = Add-CasManagedResource -State $script:state -Id created -Kind file -Ownership created -Target $target -WasPresentBefore $false + Write-CasManagedState -State $script:state -Path $script:statePath -ApprovedRoots $script:config + + $preview = Get-CasUninstallPreview -StatePath $script:statePath -ApprovedRoots @($script:root, $script:config) + + $preview.actions[0].action | Should -Be "remove-created" + Test-Path -LiteralPath $target | Should -BeTrue + } + + It "preserves observed resources" { + $target = Join-Path $script:root "existing.txt" + "user" | Set-Content -LiteralPath $target + $null = Add-CasManagedResource -State $script:state -Id observed -Kind file -Ownership observed -Target $target -WasPresentBefore $true + Write-CasManagedState -State $script:state -Path $script:statePath -ApprovedRoots $script:config + + $preview = Get-CasUninstallPreview -StatePath $script:statePath -ApprovedRoots @($script:root, $script:config) + $result = Invoke-CasUninstall -Preview $preview -ApprovedRoots @($script:root, $script:config) -Confirm:$false + + $result | Should -BeNullOrEmpty + Get-Content -LiteralPath $target | Should -Be "user" + } + + It "applies removal only to a ledger-created file" { + $owned = Join-Path $script:root "created.txt" + $unrelated = Join-Path $script:root "unrelated.txt" + "owned" | Set-Content -LiteralPath $owned + "user" | Set-Content -LiteralPath $unrelated + $null = Add-CasManagedResource -State $script:state -Id created -Kind file -Ownership created -Target $owned -WasPresentBefore $false + Write-CasManagedState -State $script:state -Path $script:statePath -ApprovedRoots $script:config + + $preview = Get-CasUninstallPreview -StatePath $script:statePath -ApprovedRoots @($script:root, $script:config) + $null = Invoke-CasUninstall -Preview $preview -ApprovedRoots @($script:root, $script:config) -Confirm:$false + + Test-Path -LiteralPath $owned | Should -BeFalse + Test-Path -LiteralPath $unrelated | Should -BeTrue + } + + It "refuses recursive removal when an owned directory contains unexpected state" { + $directory = Join-Path $script:root "created-directory" + New-Item -ItemType Directory -Path $directory | Out-Null + "user" | Set-Content -LiteralPath (Join-Path $directory "unexpected.txt") + $null = Add-CasManagedResource -State $script:state -Id directory -Kind directory -Ownership created -Target $directory -WasPresentBefore $false + Write-CasManagedState -State $script:state -Path $script:statePath -ApprovedRoots $script:config + + $preview = Get-CasUninstallPreview -StatePath $script:statePath -ApprovedRoots @($script:root, $script:config) + + { Invoke-CasUninstall -Preview $preview -ApprovedRoots @($script:root, $script:config) -Confirm:$false } | Should -Throw "*refusing recursive removal*" + Test-Path -LiteralPath (Join-Path $directory "unexpected.txt") | Should -BeTrue + } + + It "blocks the preview when ledger evidence escapes approved roots" { + $outside = Join-Path $TestDrive "outside.txt" + "user" | Set-Content -LiteralPath $outside + $null = Add-CasManagedResource -State $script:state -Id unsafe -Kind file -Ownership created -Target $outside -WasPresentBefore $false + Write-CasManagedState -State $script:state -Path $script:statePath -ApprovedRoots $script:config + + { Get-CasUninstallPreview -StatePath $script:statePath -ApprovedRoots @($script:root, $script:config) } | Should -Throw "*outside approved CAS boundaries*" + Test-Path -LiteralPath $outside | Should -BeTrue + } + + It "restores modified files from recorded backup evidence" { + $target = Join-Path $script:root "config.json" + $backup = Join-Path $script:config "config.backup.json" + '{"cas":true}' | Set-Content -LiteralPath $target + '{"user":true}' | Set-Content -LiteralPath $backup + $null = Add-CasManagedResource -State $script:state -Id modified -Kind configuration -Ownership modified -Target $target -WasPresentBefore $true -BackupTarget $backup + Write-CasManagedState -State $script:state -Path $script:statePath -ApprovedRoots $script:config + + $preview = Get-CasUninstallPreview -StatePath $script:statePath -ApprovedRoots @($script:root, $script:config) + $null = Invoke-CasUninstall -Preview $preview -ApprovedRoots @($script:root, $script:config) -Confirm:$false + + (Get-Content -LiteralPath $target -Raw | ConvertFrom-Json).user | Should -BeTrue + } +} diff --git a/uninstall.ps1 b/uninstall.ps1 index 4b1c8ae..4573307 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -1,7 +1,9 @@ [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "High")] param( [string]$RootPath, - [string]$ConfigPath + [string]$ConfigPath, + [string]$StatePath, + [switch]$Apply ) $ErrorActionPreference = "Stop" @@ -14,13 +16,18 @@ Import-Module (Join-Path $PSScriptRoot "scripts\Cas.Workstation.psm1") -Force $manifest = Get-CasManifest if (-not $RootPath) { $RootPath = Get-CasDefaultRootPath -Manifest $manifest } if (-not $ConfigPath) { $ConfigPath = Get-CasDefaultConfigPath -Manifest $manifest } +if (-not $StatePath) { $StatePath = Get-CasManagedStatePath -ConfigPath $ConfigPath -Manifest $manifest } -foreach ($target in @($RootPath, $ConfigPath)) { - if (Test-Path -LiteralPath $target) { - if ($PSCmdlet.ShouldProcess($target, "Remove CAS Workstation managed directory")) { - Remove-Item -LiteralPath $target -Recurse -Force - } - } +$approvedRoots = @($RootPath, $ConfigPath) +$preview = Get-CasUninstallPreview -StatePath $StatePath -ApprovedRoots $approvedRoots +$preview.actions | Format-Table id, ownership, action, target -AutoSize + +if (-not $Apply) { + Write-Host "Preview only. Re-run with -Apply to request removal of ledger-owned resources." + return } -Write-Host "CAS Workstation uninstall completed." +if ($PSCmdlet.ShouldProcess("$(@($preview.actions | Where-Object actionable).Count) ledger-owned resource(s)", "Apply CAS Workstation uninstall")) { + Invoke-CasUninstall -Preview $preview -ApprovedRoots $approvedRoots -Confirm:$false | Out-Null + Write-Host "CAS Workstation uninstall apply completed." +}