From bee12501cad06c5872fad9a265d10bd36b5b45be Mon Sep 17 00:00:00 2001 From: Irlan Cidade <2146925357+icidade@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:42:10 +0000 Subject: [PATCH 1/4] docs: sanitize public paths in governance overlay --- docs/cost-context-governance-design.md | 118 +++++++++++++++++++++ docs/dependencies-and-configuration.md | 8 ++ docs/github-pr-access.md | 8 +- docs/headroom-kag-selective-retrieval.md | 43 ++++++++ docs/self-update-capability.md | 4 +- scripts/export_safe_self_state.py | 12 ++- tools/headroom_phase1/hr_manual_wrapper.py | 6 +- 7 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 docs/cost-context-governance-design.md diff --git a/docs/cost-context-governance-design.md b/docs/cost-context-governance-design.md new file mode 100644 index 0000000..265032c --- /dev/null +++ b/docs/cost-context-governance-design.md @@ -0,0 +1,118 @@ +# Cost & Context Governance — v0.7.0 Phase 1 Design + +## Version +This document captures the MiniCISO-side design and documentation updates aligned to the Hermes cost/context governance v0.7.0 rollout. + +## Goal +Implement a mandatory, provider-independent governance layer for MiniCISO that preserves evidence quality and independent QA while preventing runaway token/context/tool consumption. + +## Repositories inspected +- MiniCISO overlay checkout: `` +- Hermes runtime checkout: `` + +## Existing integration points + +### MiniCISO overlay +- `profiles/chief-of-staff/SOUL.md`: core Chief-of-Staff operating instructions. Best place to make the governance skill mandatory at the procedural layer. +- `config/chief-of-staff.public.yaml`: public example config that can expose governance defaults. +- No existing MiniCISO-owned runtime package for deterministic governance. + +### Hermes runtime +- `run_agent.py`: `AIAgent` wrappers, turn execution entrypoints, tool execution dispatch, `delegate_task` dispatch, context compression hook. +- `agent/conversation_loop.py`: main model-call loop, iteration handling, context growth, turn completion path. +- `agent/context_compressor.py`: existing compaction/externalization mechanism. +- `agent/tool_executor.py`: deterministic tool execution path; best insertion point for tool-call accounting and pre-tool circuit breakers. +- `agent/tool_guardrails.py`: existing repeated-tool/no-progress primitive that can be complemented by broader engagement governance. +- `tools/delegate_tool.py`: child-agent creation, task context packaging, timeout/error/summary handling; best insertion point for child budget allocation, bounded child context manifests, and structured partial handoffs. +- `model_tools.py`: tool-definition assembly; best insertion point for exact allowlist filtering and tool-schema overhead measurement. +- `hermes_cli/config.py`: default config surface for observe/enforce/disabled modes and budget profiles. + +## Selected implementation strategy +Use a dual-layer design: + +1. **Procedural layer (MiniCISO-owned)** + - Add mandatory skill `cost-context-governance`. + - Patch Chief-of-Staff SOUL to require loading/classification on every request. + - Keep conversational requests lightweight via explicit pass-through classification. + +2. **Runtime layer (Hermes-side generic enforcement, MiniCISO-enabled by config)** + - Add a new controller module that: + - classifies requests; + - creates an engagement workspace + JSON artifacts; + - allocates hierarchical envelopes; + - records telemetry/context manifests/tool-schema overhead; + - enforces model/tool/delegation/time/context thresholds; + - preserves partial handoffs on timeout/hard stop. + - Enable by default in the active Chief-of-Staff profile config. + +## Affected files + +### New files +- `/agent/cost_context_governance.py` +- `/tests/agent/test_cost_context_governance.py` +- `/tests/tools/test_delegate.py` +- `skills/cost-context-governance/SKILL.md` +- `docs/cost-context-governance-design.md` + +### Patched Hermes files +- `run_agent.py` +- `agent/conversation_loop.py` +- `agent/tool_executor.py` +- `tools/delegate_tool.py` +- `model_tools.py` +- `hermes_cli/config.py` + +### Patched MiniCISO/profile files +- `profiles/chief-of-staff/SOUL.md` +- `/SOUL.md` +- `/config.yaml` +- `/skills/cost-context-governance/SKILL.md` + +## Runtime data layout +Under the active profile: +- `engagements//brief.json` +- `engagements//budget.json` +- `engagements//telemetry.jsonl` +- `engagements//evidence_ledger.jsonl` +- `engagements//claim_ledger.jsonl` +- `engagements//checkpoints/*.json` +- `engagements//partial_handoffs/*.json` + +## Scope of first implementation +Implemented now for immediate VPS effect: +- observe/enforce/disabled modes; +- root + child envelope accounting with file-lock persistence; +- configurable budget profiles and QA reserve; +- model/tool/delegation/time/context thresholds; +- context manifests and tool-schema overhead metrics; +- bounded child context package metadata; +- structured partial handoffs on timeout / limit / hard stop; +- role/toolset filtering via runtime allowlist intersection; +- local JSONL telemetry and engagement summaries. + +## Compatibility risks +- Hermes currently lacks a native pre-model-call governance abstraction, so the first implementation must patch runtime call sites directly. +- Tool filtering must not break existing sessions that intentionally grant broader toolsets; fallback is intersection-only when governance is active. +- Child timeout handling varies by provider/runtime path; partial handoff synthesis must work even when the provider returns no summary. +- Existing MiniCISO repo has no skill-sync convention yet; immediate VPS install will patch the active profile directly and also stage the skill inside the overlay repo. + +## Test plan +Automated tests will cover at least: +- threshold/circuit-breaker evaluation before 50 unbounded child calls; +- concurrency-safe shared root budget; +- timeout → persisted partial handoff (not null); +- QA reserve protection; +- per-task tool filtering before schema assembly; +- measurable unused tool-schema overhead; +- compaction/externalization before threshold breach; +- lightweight conversational pass-through; +- usage estimation fallback when provider metadata is missing. + +## Why MiniCISO-level enforcement alone is insufficient +MiniCISO prompt/skill instructions can require planning and checkpointing, but they cannot deterministically: +- prevent an agent from sending oversized tool schemas; +- stop a child before the next model call once budget is exhausted; +- atomically coordinate concurrent child consumption from a shared root budget; +- force structured partial handoffs when runtime timeouts occur. + +Therefore the smallest required upstream-compatible change is a generic Hermes runtime governance controller that MiniCISO enables by config. The fallback for unsupported runtimes is visible observation-only mode with explicit warning in child/task results. diff --git a/docs/dependencies-and-configuration.md b/docs/dependencies-and-configuration.md index 2c708c6..b9ac231 100644 --- a/docs/dependencies-and-configuration.md +++ b/docs/dependencies-and-configuration.md @@ -69,6 +69,7 @@ env -u VIRTUAL_ENV uv run bigua-analyzer --help - KAG query builder - deterministic retrieval selector - manual wrapper with shadow-mode logging +- RTK execution output optimizer experiment for narrow operational command classes **Repo-side location:** - `tools/headroom_phase1/` @@ -79,6 +80,13 @@ env -u VIRTUAL_ENV uv run bigua-analyzer --help - no raw evidence artifacts in the repo - absence in retrieval pack must remain `not_verified_in_raw` - keep selection-first logs and code separable from confidential engagement data +- RTK reduced output is never authoritative +- RTK default mode is `shadow` +- `MINICISO_EXECUTION_OUTPUT_OPTIMIZER=0` must preserve rollback to passthrough + +**RTK MVP scope:** +- included: `git_status`, `git_diff_stat`, `ls`, `find`, `tree`, `git_fetch` +- excluded: `read_file`, `search_files`, `grep`, reports/findings, SARIF, SBOM, PoCs, HTTP traces, SME/Security QA responses ### ProjectDiscovery Cloud / passive discovery layer **Purpose:** passive asset discovery and cloud-assisted recon support when the assessment model includes authorized external inventory work. diff --git a/docs/github-pr-access.md b/docs/github-pr-access.md index 0c2cb94..8ceced7 100644 --- a/docs/github-pr-access.md +++ b/docs/github-pr-access.md @@ -40,7 +40,7 @@ If you use a classic PAT instead of a fine-grained one: Store it outside the repo, in the active profile: ```bash -/home/vpsadmin/.hermes/profiles/chief-of-staff/.env +/.env ``` Add: @@ -74,8 +74,8 @@ gh auth status Always valid: ```bash -git -C /home/vpsadmin/miniCISO remote -v -git -C /home/vpsadmin/miniCISO ls-remote origin +git -C remote -v +git -C ls-remote origin ``` To validate the API directly: @@ -97,7 +97,7 @@ PY ### With `gh` ```bash -cd /home/vpsadmin/miniCISO +cd git checkout -b chore/my-change # edit / export / validate git add -A diff --git a/docs/headroom-kag-selective-retrieval.md b/docs/headroom-kag-selective-retrieval.md index 8c1deef..78521a7 100644 --- a/docs/headroom-kag-selective-retrieval.md +++ b/docs/headroom-kag-selective-retrieval.md @@ -45,6 +45,49 @@ The initial recommendation is to run in **shadow mode**: 4. continue running the raw/full flow in parallel; 5. compare savings, recovered evidence, and `decision_delta`. +## RTK execution output optimizer (experimental) + +A second, narrower experiment now lives beside the wrapper in `tools/headroom_phase1/`: the **RTK execution output optimizer**. + +Its contract is intentionally strict: + +- `raw` remains authoritative at all times; +- the optimizer runs only in **shadow mode** by default; +- the reduced view is derivative/log-only and never replaces the effective payload; +- a single env var kill switch restores baseline behavior immediately. + +### Included MVP operation classes + +- `git_status` +- `git_diff_stat` +- `ls` +- `find` +- `tree` +- `git_fetch` + +### Explicitly excluded from this MVP + +- `read_file` +- `search_files` +- `grep` +- evidence artifacts +- reports / findings +- SARIF / SBOM / PoC material +- HTTP traces +- SME or Security QA responses + +### Runtime knobs + +```text +MINICISO_EXECUTION_OUTPUT_OPTIMIZER=1 +MINICISO_EXECUTION_OUTPUT_OPTIMIZER_MODE=shadow +MINICISO_EXECUTION_OUTPUT_OPTIMIZER_ALLOWLIST=git_status,git_diff_stat,ls,find,tree,git_fetch +``` + +### Rollback rule + +Set `MINICISO_EXECUTION_OUTPUT_OPTIMIZER=0` to force passthrough and preserve the pre-experiment baseline without changing the wrapper's real output path. + ## Minimum pack provenance Each selected slice must preserve, at minimum: diff --git a/docs/self-update-capability.md b/docs/self-update-capability.md index fdf8aef..0147bdd 100644 --- a/docs/self-update-capability.md +++ b/docs/self-update-capability.md @@ -55,8 +55,8 @@ You can also point to explicit sources: ```bash python3 scripts/export_safe_self_state.py \ - --source-workspace /home/vpsadmin/miniciso-security \ - --source-profile /home/vpsadmin/.hermes/profiles/chief-of-staff \ + --source-workspace \ + --source-profile \ --apply ``` diff --git a/scripts/export_safe_self_state.py b/scripts/export_safe_self_state.py index c381018..9526f35 100644 --- a/scripts/export_safe_self_state.py +++ b/scripts/export_safe_self_state.py @@ -37,8 +37,16 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Export public/safe MiniCISO state from the VPS into this repo.") parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[1])) - parser.add_argument("--source-workspace", default="/home/vpsadmin/miniciso-security") - parser.add_argument("--source-profile", default="/home/vpsadmin/.hermes/profiles/chief-of-staff") + parser.add_argument( + "--source-workspace", + default=str(Path.home() / "miniciso-security"), + help="Path to the local MiniCISO workspace to export from.", + ) + parser.add_argument( + "--source-profile", + default=str(Path.home() / ".hermes/profiles/chief-of-staff"), + help="Path to the local Hermes profile root to export from.", + ) parser.add_argument("--apply", action="store_true", help="Actually write files. Default is dry-run.") return parser.parse_args() diff --git a/tools/headroom_phase1/hr_manual_wrapper.py b/tools/headroom_phase1/hr_manual_wrapper.py index 50b5956..0f5986a 100755 --- a/tools/headroom_phase1/hr_manual_wrapper.py +++ b/tools/headroom_phase1/hr_manual_wrapper.py @@ -98,7 +98,11 @@ def parse_args() -> argparse.Namespace: help="Human/QA verdict for this artifact run", ) p.add_argument("--note", default="", help="Free-form operator note") - p.add_argument("--log-dir", default="/home/vpsadmin/miniciso-security/headroom_phase1/logs", help="Log directory") + p.add_argument( + "--log-dir", + default=str(Path.home() / "miniciso-security/headroom_phase1/logs"), + help="Log directory", + ) p.add_argument("--selection-index", default="", help="Path to structural index JSON for selection-first shadow mode") p.add_argument("--selection-query", default="", help="Path to KAG query JSON for selection-first shadow mode") p.add_argument("--selection-pack", default="", help="Path to retrieval pack JSON for selection-first shadow mode") From 9790355f8392fd20dc62de8dc477c5be8618c0ad Mon Sep 17 00:00:00 2001 From: Irlan Cidade <2146925357+icidade@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:49:38 +0000 Subject: [PATCH 2/4] fix: respect HERMES_HOME in bootstrap and smoke tests --- docs/cost-context-governance-design.md | 2 +- docs/github-pr-access.md | 6 +++--- docs/self-update-capability.md | 4 ++-- scripts/bootstrap.ps1 | 2 +- scripts/bootstrap.sh | 2 +- scripts/smoke-test.ps1 | 3 ++- scripts/smoke-test.sh | 2 +- 7 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/cost-context-governance-design.md b/docs/cost-context-governance-design.md index 265032c..89e80ac 100644 --- a/docs/cost-context-governance-design.md +++ b/docs/cost-context-governance-design.md @@ -7,7 +7,7 @@ This document captures the MiniCISO-side design and documentation updates aligne Implement a mandatory, provider-independent governance layer for MiniCISO that preserves evidence quality and independent QA while preventing runaway token/context/tool consumption. ## Repositories inspected -- MiniCISO overlay checkout: `` +- MiniCISO overlay checkout: `` - Hermes runtime checkout: `` ## Existing integration points diff --git a/docs/github-pr-access.md b/docs/github-pr-access.md index 8ceced7..3e27953 100644 --- a/docs/github-pr-access.md +++ b/docs/github-pr-access.md @@ -74,8 +74,8 @@ gh auth status Always valid: ```bash -git -C remote -v -git -C ls-remote origin +git -C remote -v +git -C ls-remote origin ``` To validate the API directly: @@ -97,7 +97,7 @@ PY ### With `gh` ```bash -cd +cd git checkout -b chore/my-change # edit / export / validate git add -A diff --git a/docs/self-update-capability.md b/docs/self-update-capability.md index 0147bdd..74295c4 100644 --- a/docs/self-update-capability.md +++ b/docs/self-update-capability.md @@ -55,8 +55,8 @@ You can also point to explicit sources: ```bash python3 scripts/export_safe_self_state.py \ - --source-workspace \ - --source-profile \ + --source-workspace \ + --source-profile \ --apply ``` diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index 5316f67..c346b71 100644 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -91,7 +91,7 @@ if (-not $SkipProviderSetup) { Invoke-Hermes -Arguments @('setup') } -$profileRoot = Join-Path $HOME '.hermes\profiles' +$profileRoot = Join-Path $HermesHome 'profiles' $profiles = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'profiles') -Directory | Sort-Object Name if ($profiles.Count -ne 9) { throw "Expected 9 profiles; found $($profiles.Count)." } diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 334c760..773b580 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -72,7 +72,7 @@ if [[ "$SKIP_PROVIDER_SETUP" == false ]]; then hermes setup fi -profile_root="$HOME/.hermes/profiles" +profile_root="$HERMES_HOME/profiles" profiles=() for profile_dir in "$REPO_ROOT"/profiles/*; do [[ -d "$profile_dir" ]] && profiles+=("${profile_dir##*/}") diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index 1779206..8b616fd 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -3,6 +3,7 @@ param([switch]$Online) $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$hermesHome = if ($env:HERMES_HOME) { $env:HERMES_HOME } else { Join-Path $HOME '.hermes' } $profiles = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'profiles') -Directory | Sort-Object Name $hermes = Get-Command hermes -ErrorAction Stop $profileList = (& $hermes.Source profile list 2>&1 | Out-String) @@ -12,7 +13,7 @@ foreach ($profile in $profiles) { if ($profileList -notmatch [regex]::Escape($profile.Name)) { throw "Profile not registered in Hermes: $($profile.Name)" } - $installedSoul = Join-Path $HOME ".hermes\profiles\$($profile.Name)\SOUL.md" + $installedSoul = Join-Path $hermesHome "profiles\$($profile.Name)\SOUL.md" if (-not (Test-Path -LiteralPath $installedSoul)) { throw "SOUL.md not installed: $($profile.Name)" } diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 5d0b619..cb9f2f1 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -15,7 +15,7 @@ done for profile in "${profiles[@]}"; do grep -Fq "$profile" <<<"$profile_list" || { echo "Profile not registered: $profile" >&2; exit 1; } - [[ -f "$HOME/.hermes/profiles/$profile/SOUL.md" ]] || { echo "SOUL.md not installed: $profile" >&2; exit 1; } + [[ -f "${HERMES_HOME:-$HOME/.hermes}/profiles/$profile/SOUL.md" ]] || { echo "SOUL.md not installed: $profile" >&2; exit 1; } echo "OK: $profile" if [[ "$ONLINE" == true ]]; then hermes -p "$profile" chat -Q -q 'Answer in one line starting with OK and state your role in MiniCISO.' From adc28f4f8df9c1f8d968a33d46e295ad8dbbd554 Mon Sep 17 00:00:00 2001 From: Irlan Cidade <2146925357+icidade@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:23:35 +0000 Subject: [PATCH 3/4] feat: pin governed Hermes runtime for v0.7.0 --- INSTALL.md | 16 +- chief-of-staff/SOUL-miniciso-snippet.md | 18 +- config/chief-of-staff.public.yaml | 131 ++++++++++- config/hermes-version.env | 10 +- docs/cost-context-governance-design.md | 8 +- docs/dependencies-and-configuration.md | 6 +- docs/repo-architecture.md | 8 +- meta/MANIFEST.json | 7 +- meta/SUMMARY.json | 8 +- profiles/chief-of-staff/SOUL.md | 20 +- scripts/bootstrap.ps1 | 176 +++++++-------- scripts/bootstrap.sh | 167 +++++++++----- scripts/check_bundled_capabilities.py | 9 +- scripts/install_governance_config.py | 79 +++++++ scripts/smoke-test.ps1 | 53 ++--- scripts/smoke-test.sh | 57 +++-- .../tests/test_check_bundled_capabilities.py | 12 +- .../test_runtime_pin_bootstrap_config.py | 155 +++++++++++++ scripts/validate-repo.ps1 | 15 +- scripts/validate-repo.sh | 9 +- skills/cost-context-governance/SKILL.md | 204 ++++++++++++++++++ .../execution_output_optimizer.py | 180 ++++++++++++++++ tools/headroom_phase1/hr_manual_wrapper.py | 13 ++ .../tests/test_execution_output_optimizer.py | 73 +++++++ .../tests/test_hr_manual_wrapper.py | 48 ++++- 25 files changed, 1258 insertions(+), 224 deletions(-) create mode 100644 scripts/install_governance_config.py create mode 100644 scripts/tests/test_runtime_pin_bootstrap_config.py create mode 100644 skills/cost-context-governance/SKILL.md create mode 100644 tools/headroom_phase1/execution_output_optimizer.py create mode 100644 tools/headroom_phase1/tests/test_execution_output_optimizer.py diff --git a/INSTALL.md b/INSTALL.md index 99e2202..78f1a27 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -3,10 +3,10 @@ ## Prerequisites - Git -- HTTPS access to GitHub and the endpoints used by the official Hermes installer +- HTTPS access to GitHub and the endpoints used by the pinned Hermes fork installer - a valid credential for at least one Hermes-supported provider/model -Python, `uv`, Node.js, and the remaining runtime dependencies are managed by the official installer pinned by the project. +Python, `uv`, Node.js, and the remaining runtime dependencies are managed by the installer from the pinned fork commit. ## Clean installation @@ -30,8 +30,8 @@ Provider setup is interactive because credentials do not belong in Git. To prepa ## What is reproducible -- Hermes upstream tag and commit -- upstream installer checksums +- Hermes fork repository and exact commit +- fork installer checksums - profile names and prompts - workspace structure - terminal backend and working directory @@ -57,14 +57,16 @@ The bootstrap creates a `.pre-miniciso` copy before replacing an existing differ ## Updating Hermes -Do not use a floating branch. Update `config/hermes-version.env` with an official release, its resolved commit, and the hashes of both installers. Then run the validations and test a clean restoration. +Do not use a floating branch. Update `config/hermes-version.env` with a known Hermes fork repository, exact commit, and the hashes of both installers. The fork preserves its upstream relationship but is the canonical source for this overlay release. ## Rollback -1. check out a previous commit from this repo; -2. run bootstrap again; +1. choose a previously known-good Hermes SHA in `config/hermes-version.env`; +2. run bootstrap again with that pin; 3. restore any `SOUL.md.pre-miniciso` only if you want to stop using the overlay-managed prompt. +Rollback does not use a floating branch or destructive `git reset`/`git clean` operation. + ## Verification ```powershell diff --git a/chief-of-staff/SOUL-miniciso-snippet.md b/chief-of-staff/SOUL-miniciso-snippet.md index 804c738..f4d6f8a 100644 --- a/chief-of-staff/SOUL-miniciso-snippet.md +++ b/chief-of-staff/SOUL-miniciso-snippet.md @@ -1,4 +1,20 @@ -## MiniCISO Staff orchestration +## Cost & Context Governance + +You must load and follow `cost-context-governance` for **every request**. + +Mandatory behavior: +- classify every request as `conversational`, `bounded`, or `engagement` before deep execution; +- keep governance overhead minimal for simple conversation; +- create a resource/context plan before tool-heavy, delegated, investigative, implementation, or assessment work; +- preserve QA reserve for any lane that will require independent validation; +- allocate bounded child budgets and bounded child context packages; +- restrict tools by role and task; +- checkpoint or persist partial handoff before timeout-prone or budget-limited stops; +- summarize actual resource usage at closure. + +This governance layer controls execution and context/resource use. It does not replace KAG, Headroom, Institutional Learning, or Security QA. + +## MiniCISO Staff orchestration When the user asks for MiniCISO or security staff work, orchestrate the MiniCISO Staff V2 operating model: diff --git a/config/chief-of-staff.public.yaml b/config/chief-of-staff.public.yaml index 407b11e..0319f5d 100644 --- a/config/chief-of-staff.public.yaml +++ b/config/chief-of-staff.public.yaml @@ -8,7 +8,9 @@ model: toolsets: - hermes-cli agent: - max_turns: 90 + # Legacy ceiling remains as a secondary failsafe. The governance controller + # below must stop or pause execution earlier for bounded and engagement work. + max_turns: 24 tool_use_enforcement: auto task_completion_guidance: true environment_probe: true @@ -36,10 +38,137 @@ browser: dialog_timeout_s: 300 compression: enabled: true + # Existing compression still helps, but governance should compact or + # externalize before the old late-compression failure mode recurs. threshold: 0.5 target_ratio: 0.2 protect_last_n: 20 protect_first_n: 3 +cost_context_governance: + # v0.7.0 installed default. Use observe for diagnostics/migration, disabled + # only by explicit operator choice. + mode: enforce + workspace_dir: engagements + default_profile: conversational + force_for_profiles: + - chief-of-staff + engagement_keywords: + - miniciso + - security + - assessment + - review + - threat model + - appsec + - bug bounty + threshold: + informational_ratio: 0.5 + warning_ratio: 0.7 + approval_ratio: 0.85 + hard_stop_ratio: 1.0 + progress: + equivalent_tool_calls: 3 + same_error_repeats: 3 + no_progress_iterations: 3 + timeout_checkpoint_seconds: 45 + role_toolsets: + chief: + - delegation + - todo + - session_search + - file + - search + - skills + - browser + - web + sme: + - file + - search + - terminal + - web + - browser + qa: + - file + - search + - terminal + - web + - browser + - session_search + profiles: + # Conversational: minimal overhead, no delegation, small schema/call budget. + conversational: + total_model_calls: 3 + calls_per_agent: 3 + calls_per_delegated_task: 0 + input_tokens: 6000 + output_tokens: 3000 + total_tokens: 9000 + context_tokens_per_request: 5000 + wall_clock_seconds: 90 + tool_invocations: 6 + retries: 1 + delegation_count: 0 + iterations_without_progress: 2 + qa_reserve_ratio: 0.0 + # Bounded: short implementation/review/investigation with one narrow child. + bounded: + total_model_calls: 6 + calls_per_agent: 5 + calls_per_delegated_task: 3 + input_tokens: 18000 + output_tokens: 8000 + total_tokens: 26000 + context_tokens_per_request: 9000 + wall_clock_seconds: 180 + tool_invocations: 15 + retries: 3 + delegation_count: 1 + iterations_without_progress: 3 + qa_reserve_ratio: 0.1 + # Standard engagement: typical MiniCISO assessment with SMEs plus protected QA. + standard_engagement: + total_model_calls: 8 + calls_per_agent: 6 + calls_per_delegated_task: 5 + input_tokens: 24000 + output_tokens: 12000 + total_tokens: 36000 + context_tokens_per_request: 12000 + wall_clock_seconds: 300 + tool_invocations: 25 + retries: 4 + delegation_count: 3 + iterations_without_progress: 4 + qa_reserve_ratio: 0.2 + # Deep engagement: allows more lanes, but still far below the uncontrolled incident pattern. + deep_engagement: + total_model_calls: 16 + calls_per_agent: 10 + calls_per_delegated_task: 8 + input_tokens: 60000 + output_tokens: 24000 + total_tokens: 84000 + context_tokens_per_request: 18000 + wall_clock_seconds: 600 + tool_invocations: 50 + retries: 6 + delegation_count: 4 + iterations_without_progress: 5 + qa_reserve_ratio: 0.25 + # Custom stays public and provider-independent; operators can tune as needed. + custom: + total_model_calls: 10 + calls_per_agent: 7 + calls_per_delegated_task: 4 + input_tokens: 30000 + output_tokens: 12000 + total_tokens: 42000 + context_tokens_per_request: 12000 + wall_clock_seconds: 300 + tool_invocations: 20 + retries: 4 + delegation_count: 2 + iterations_without_progress: 3 + qa_reserve_ratio: 0.15 auxiliary: vision: provider: auto diff --git a/config/hermes-version.env b/config/hermes-version.env index 0bff3b3..33b7fde 100644 --- a/config/hermes-version.env +++ b/config/hermes-version.env @@ -1,6 +1,6 @@ # Hermes Agent release pinned for reproducible installation. -HERMES_REPOSITORY=https://github.com/NousResearch/hermes-agent.git -HERMES_TAG=v2026.6.19 -HERMES_COMMIT=2bd1977d8fad185c9b4be47884f7e87f1add0ce3 -HERMES_INSTALL_PS1_SHA256=C35FA215946381E6A843A4DDE314336A713E7444832435C9D3F1BD9A85320198 -HERMES_INSTALL_SH_SHA256=DBD9D555ED4AC67BD1FC71BA6A39B410CF2AF0EBCFD8F4889E086AF78C9DDCAA +HERMES_REPOSITORY=https://github.com/icidade/hermes-agent.git +HERMES_TAG=v0.7.0-fork-pin +HERMES_COMMIT=489c6f2103ccca0ac1fc4f6249c71924ec8f024c +HERMES_INSTALL_PS1_SHA256=226C70A90AD47E8A4D34CB11ACA4ECBEB649E2F9B67FBD009EA49791DE2D56F5 +HERMES_INSTALL_SH_SHA256=5854B15670B51A8DAAE8F59DDFA917062DE9F74BE261EB73B4B8D719710F8968 diff --git a/docs/cost-context-governance-design.md b/docs/cost-context-governance-design.md index 89e80ac..390178d 100644 --- a/docs/cost-context-governance-design.md +++ b/docs/cost-context-governance-design.md @@ -15,7 +15,7 @@ Implement a mandatory, provider-independent governance layer for MiniCISO that p ### MiniCISO overlay - `profiles/chief-of-staff/SOUL.md`: core Chief-of-Staff operating instructions. Best place to make the governance skill mandatory at the procedural layer. - `config/chief-of-staff.public.yaml`: public example config that can expose governance defaults. -- No existing MiniCISO-owned runtime package for deterministic governance. +- The governed runtime is supplied by the `icidade/hermes-agent` fork pinned by exact SHA in `config/hermes-version.env`; MiniCISO remains the overlay and does not vendor Hermes. ### Hermes runtime - `run_agent.py`: `AIAgent` wrappers, turn execution entrypoints, tool execution dispatch, `delegate_task` dispatch, context compression hook. @@ -91,10 +91,10 @@ Implemented now for immediate VPS effect: - local JSONL telemetry and engagement summaries. ## Compatibility risks -- Hermes currently lacks a native pre-model-call governance abstraction, so the first implementation must patch runtime call sites directly. +- The release depends on the pinned fork runtime containing the governance implementation; bootstrap verifies repository, HEAD, executable, and Python module provenance before installing the overlay. - Tool filtering must not break existing sessions that intentionally grant broader toolsets; fallback is intersection-only when governance is active. - Child timeout handling varies by provider/runtime path; partial handoff synthesis must work even when the provider returns no summary. -- Existing MiniCISO repo has no skill-sync convention yet; immediate VPS install will patch the active profile directly and also stage the skill inside the overlay repo. +- The bootstrap installs the overlay skill and merges public governance only into the `chief-of-staff` profile; no runtime patchset or private profile state is required. ## Test plan Automated tests will cover at least: @@ -115,4 +115,4 @@ MiniCISO prompt/skill instructions can require planning and checkpointing, but t - atomically coordinate concurrent child consumption from a shared root budget; - force structured partial handoffs when runtime timeouts occur. -Therefore the smallest required upstream-compatible change is a generic Hermes runtime governance controller that MiniCISO enables by config. The fallback for unsupported runtimes is visible observation-only mode with explicit warning in child/task results. +Therefore the smallest required runtime change is the generic governance controller published in the pinned Hermes fork, which MiniCISO enables by config. Headroom Phase 1 remains shadow-only; Headroom Phase 2 is outside v0.7.0. diff --git a/docs/dependencies-and-configuration.md b/docs/dependencies-and-configuration.md index b9ac231..e550566 100644 --- a/docs/dependencies-and-configuration.md +++ b/docs/dependencies-and-configuration.md @@ -10,7 +10,9 @@ Required for the overlay to be useful at all: - network access during the first installation - the Hermes version pinned in `config/hermes-version.env` -The bootstrap delegates Python, `uv`, Node.js, and runtime dependency management to the pinned official Hermes installer. +The bootstrap delegates Python, `uv`, Node.js, and runtime dependency management to the Hermes installer obtained from the pinned fork commit. The fork is the canonical runtime source for this release; no floating branch or vendored Hermes copy is used. + +The runtime pin is the exact SHA in `config/hermes-version.env`. The installer is fetched from that same repository and verified by SHA-256 before use. MiniCISO remains an overlay: prompts, profiles, skills, templates, and configuration stay in this repository. ### 2. MiniCISO overlay content dependencies Needed to use the prompts/profiles/templates effectively: @@ -84,6 +86,8 @@ env -u VIRTUAL_ENV uv run bigua-analyzer --help - RTK default mode is `shadow` - `MINICISO_EXECUTION_OUTPUT_OPTIMIZER=0` must preserve rollback to passthrough +Phase 2 is outside the v0.7.0 scope. + **RTK MVP scope:** - included: `git_status`, `git_diff_stat`, `ls`, `find`, `tree`, `git_fetch` - excluded: `read_file`, `search_files`, `grep`, reports/findings, SARIF, SBOM, PoCs, HTTP traces, SME/Security QA responses diff --git a/docs/repo-architecture.md b/docs/repo-architecture.md index c41626e..609ca5a 100644 --- a/docs/repo-architecture.md +++ b/docs/repo-architecture.md @@ -2,12 +2,12 @@ ## Preferred model -Use **Hermes as an upstream dependency** and keep MiniCISO as a separate public, sanitized overlay repository. +Use Hermes as a separately installed runtime dependency and keep MiniCISO as a separate public, sanitized overlay repository. This release obtains that runtime from the `icidade/hermes-agent` fork at the exact SHA recorded in `config/hermes-version.env`. That means: - Hermes core stays updateable with low friction. - MiniCISO prompts, profiles, templates, and docs remain your own product layer. -- A fork of Hermes becomes necessary only if you must patch Hermes core behavior. +- The fork preserves the upstream relationship while providing the runtime integration required by this release; it is not vendored into MiniCISO. ## Recommended repository structure @@ -97,7 +97,9 @@ Choose fork only when you need to change: ## Promotion path 1. Author and review content in this repo. -2. Pin an official Hermes release and commit in `config/hermes-version.env`. +2. Pin the known Hermes fork repository and exact commit in `config/hermes-version.env`. 3. Restore with the platform bootstrap, which creates dedicated named profiles. 4. Validate with the offline validator and runtime smoke test. 5. Promote to daily use only after review of the local provider and isolation settings. + +Rollback uses a previously known-good runtime SHA by reinstalling that version through the bootstrap. It does not use a floating branch or destructive `reset`/`clean` operation. diff --git a/meta/MANIFEST.json b/meta/MANIFEST.json index 493f6a4..3cc660c 100644 --- a/meta/MANIFEST.json +++ b/meta/MANIFEST.json @@ -2,9 +2,9 @@ "package": "miniCISO", "repository": "https://github.com/icidade/miniCISO", "runtime": { - "repository": "https://github.com/NousResearch/hermes-agent", - "tag": "v2026.6.19", - "commit": "2bd1977d8fad185c9b4be47884f7e87f1add0ce3" + "repository": "https://github.com/icidade/hermes-agent.git", + "tag": "v0.7.0-fork-pin", + "commit": "489c6f2103ccca0ac1fc4f6249c71924ec8f024c" }, "sanitized": true, "included_profiles": [ @@ -19,6 +19,7 @@ "security-qa" ], "bundled_skills": [ + "cost-context-governance", "miniciso-kag-finding-gate", "miniciso-headroom-phase1", "miniciso-institutional-learning" diff --git a/meta/SUMMARY.json b/meta/SUMMARY.json index 80a91a8..584eab6 100644 --- a/meta/SUMMARY.json +++ b/meta/SUMMARY.json @@ -2,7 +2,7 @@ "package": "miniCISO", "repo_ready": true, "clean_restore_supported": true, - "runtime_pin": "2bd1977d8fad185c9b4be47884f7e87f1add0ce3", + "runtime_pin": "489c6f2103ccca0ac1fc4f6249c71924ec8f024c", "sanitized": true, "files": [ "INSTALL.md", @@ -34,18 +34,22 @@ "profiles/security-qa/SOUL.md", "profiles/security-recon-attack-surface-strategist/SOUL.md", "profiles/security-threat-modeling/SOUL.md", + "skills/cost-context-governance/SKILL.md", "skills/security/miniciso-headroom-phase1/SKILL.md", "skills/security/miniciso-institutional-learning/SKILL.md", "skills/security/miniciso-kag-finding-gate/SKILL.md", + "scripts/bootstrap.ps1", "scripts/bootstrap.sh", "scripts/check_bundled_capabilities.py", "scripts/export_safe_self_state.py", "scripts/generate_service_catalog_pdf.py", + "scripts/install_governance_config.py", "scripts/smoke-test.ps1", "scripts/smoke-test.sh", "scripts/sync_to_hermes.sh", "scripts/tests/test_check_bundled_capabilities.py", + "scripts/tests/test_runtime_pin_bootstrap_config.py", "scripts/validate-repo.ps1", "scripts/validate-repo.sh", "templates/adversarial-finding-validation-prompt.md", @@ -53,10 +57,12 @@ "templates/intake-template.md", "templates/report-template.md", "tools/headroom_phase1/README.md", + "tools/headroom_phase1/execution_output_optimizer.py", "tools/headroom_phase1/hr_index_artifact.py", "tools/headroom_phase1/hr_kag_query.py", "tools/headroom_phase1/hr_manual_wrapper.py", "tools/headroom_phase1/hr_selective_retrieval.py", + "tools/headroom_phase1/tests/test_execution_output_optimizer.py", "tools/headroom_phase1/tests/test_hr_index_artifact.py", "tools/headroom_phase1/tests/test_hr_kag_query.py", "tools/headroom_phase1/tests/test_hr_manual_wrapper.py", diff --git a/profiles/chief-of-staff/SOUL.md b/profiles/chief-of-staff/SOUL.md index fb5bb15..fc0e854 100644 --- a/profiles/chief-of-staff/SOUL.md +++ b/profiles/chief-of-staff/SOUL.md @@ -27,6 +27,22 @@ When multiple paths are possible: You are not a passive chatbot. Act as a thoughtful chief of staff helping the user make progress on meaningful goals. +## Cost & Context Governance + +You must load and follow `cost-context-governance` for **every request**. + +Mandatory behavior: +- classify every request as `conversational`, `bounded`, or `engagement` before deep execution; +- keep governance overhead minimal for simple conversation; +- create a resource/context plan before tool-heavy, delegated, investigative, implementation, or assessment work; +- preserve QA reserve for any lane that will require independent validation; +- allocate bounded child budgets and bounded child context packages; +- restrict tools by role and task; +- checkpoint or persist partial handoff before timeout-prone or budget-limited stops; +- summarize actual resource usage at closure. + +This governance layer controls execution and context/resource use. It does not replace KAG, Headroom, Institutional Learning, or Security QA. + ## MiniCISO Staff orchestration When the user asks for MiniCISO or security staff work, orchestrate the MiniCISO Staff V2 operating model: @@ -58,16 +74,18 @@ Final reports must pass through `security-qa` before delivery. If a request is o The `chief-of-staff` profile ships with bundled MiniCISO skills and should load them when their trigger conditions apply: +- `cost-context-governance`: mandatory for every request; lightweight for conversation, explicit for bounded/engagement work. - `miniciso-kag-finding-gate`: mandatory for external finding triage, bug bounty decisions, and any GO / RESEARCH / NO-GO pre-submission call. - `miniciso-headroom-phase1`: use for large structured artifacts that need deterministic, selection-first retrieval. - `miniciso-institutional-learning`: use when prior lessons learned should tighten claims, confidence, or evidence thresholds. Default expectations: +- governance is always active, but simple conversation should remain lightweight; - external finding/report decisions must use the KAG gate before drafting; - lessons learned may constrain reasoning but never replace current-case evidence; - final reports still require the `security-qa` pass before delivery. ## Output encoding -When generating Markdown reports in PT-BR for the user, write `.md` files as UTF-8 with BOM (`utf-8-sig`). This prevents accent mojibake in Telegram/mobile/desktop viewers. Before delivery/package, verify `file -bi ` reports UTF-8 and `xxd -l 3 -p ` returns `efbbbf` +When generating Markdown reports in PT-BR for the user, write `.md` files as UTF-8 with BOM (`utf-8-sig`). This prevents accent mojibake in Telegram/mobile/desktop viewers. Before delivery/package, verify `file -bi ` reports UTF-8 and `xxd -l 3 -p ` returns `efbbbf`. diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index c346b71..5cd7df3 100644 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -3,140 +3,122 @@ param( [string]$HermesHome = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { Join-Path $env:LOCALAPPDATA 'hermes' }), [string]$WorkspaceRoot = $(Join-Path $HOME 'miniciso-security'), [switch]$SkipHermesInstall, - [switch]$SkipProviderSetup + [switch]$SkipProviderSetup, + [switch]$ForceHermesReinstall ) $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $versionFile = Join-Path $repoRoot 'config\hermes-version.env' +$env:HERMES_HOME = $HermesHome -function Read-VersionFile { - param([string]$Path) +function Read-VersionFile([string]$Path) { $values = @{} foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) { - if ($line -match '^([A-Z0-9_]+)=(.+)$') { - $values[$matches[1]] = $matches[2].Trim() - } + if ($line -match '^([A-Z0-9_]+)=(.+)$') { $values[$matches[1]] = $matches[2].Trim() } } return $values } -function Invoke-Hermes { - param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) - & $script:HermesCommand @Arguments - if ($LASTEXITCODE -ne 0) { - throw "Hermes failed: hermes $($Arguments -join ' ')" - } +function Canonical-Repo([string]$Value) { + $trimmed = $Value -replace '\.git$', '' + if ($trimmed -match '^https://github\.com/([^/]+)/([^/]+)$') { return "$($matches[1])/$($matches[2])" } + if ($trimmed -match '^git@github\.com:([^/]+)/([^/]+)$') { return "$($matches[1])/$($matches[2])" } + throw 'Unsupported Hermes repository URL.' } -function Resolve-HermesCommand { - $command = Get-Command hermes -ErrorAction SilentlyContinue - if ($command) { return $command.Source } +function Fail-Provenance([string]$Reason) { throw "Hermes provenance check failed: $Reason" } - $candidates = @( - (Join-Path $HermesHome 'bin\hermes.exe'), - (Join-Path $HermesHome 'bin\hermes.cmd'), - (Join-Path $HermesHome 'bin\hermes.ps1'), - (Join-Path $HOME '.local\bin\hermes.exe'), - (Join-Path $HOME '.local\bin\hermes.cmd'), - (Join-Path $HOME '.local\bin\hermes.ps1') - ) - foreach ($candidate in $candidates) { - if (Test-Path -LiteralPath $candidate) { return $candidate } +function Prepare-Checkout([string]$RuntimeDir, [string]$ExpectedRepo, [string]$ExpectedCommit) { + $expectedOwnerRepo = Canonical-Repo $ExpectedRepo + if (-not (Test-Path -LiteralPath (Join-Path $RuntimeDir '.git'))) { + New-Item -ItemType Directory -Path $RuntimeDir -Force | Out-Null + & git -C $RuntimeDir init -q + & git -C $RuntimeDir remote add origin $ExpectedRepo } - throw 'hermes command not found after installation. Open a new terminal and rerun with -SkipHermesInstall.' + & git -C $RuntimeDir rev-parse --is-inside-work-tree *> $null + if ($LASTEXITCODE -ne 0) { Fail-Provenance 'checkout is not a Git repository' } + $origin = (& git -C $RuntimeDir config --get remote.origin.url 2>$null).Trim() + if (-not $origin -or (Canonical-Repo $origin) -ne $expectedOwnerRepo) { Fail-Provenance 'origin does not match configured repository' } + & git -C $RuntimeDir cat-file -e "$ExpectedCommit^{commit}" 2>$null + if ($LASTEXITCODE -ne 0) { & git -C $RuntimeDir fetch --no-tags --depth=1 origin $ExpectedCommit } + & git -C $RuntimeDir checkout --detach $ExpectedCommit | Out-Null } -if (-not (Test-Path -LiteralPath $versionFile)) { - throw "Missing version file: $versionFile" +function Verify-Provenance([string]$RuntimeDir, [string]$ExpectedRepo, [string]$ExpectedCommit) { + $expectedOwnerRepo = Canonical-Repo $ExpectedRepo + & git -C $RuntimeDir rev-parse --is-inside-work-tree *> $null + if ($LASTEXITCODE -ne 0) { Fail-Provenance 'checkout is not a Git repository' } + $head = (& git -C $RuntimeDir rev-parse HEAD).Trim() + if ($head -ne $ExpectedCommit) { Fail-Provenance 'HEAD does not match configured commit' } + $origin = (& git -C $RuntimeDir config --get remote.origin.url).Trim() + if ((Canonical-Repo $origin) -ne $expectedOwnerRepo) { Fail-Provenance 'origin does not match configured repository' } + $hermes = Join-Path $RuntimeDir 'venv\Scripts\hermes.exe' + $python = Join-Path $RuntimeDir 'venv\Scripts\python.exe' + if (-not (Test-Path -LiteralPath $hermes -PathType Leaf)) { Fail-Provenance 'Hermes executable is outside the governed checkout or missing' } + if (-not (Test-Path -LiteralPath $python -PathType Leaf)) { Fail-Provenance 'Hermes Python is outside the governed checkout or missing' } + $probe = 'import importlib,pathlib,sys; runtime=pathlib.Path(sys.argv[1]).resolve(); names=("hermes_cli","hermes_cli.config","hermes_constants"); [(_ for _ in ()).throw(SystemExit("Hermes module outside governed checkout: "+n)) for n in names if runtime not in pathlib.Path(importlib.import_module(n).__file__).resolve().parents]; [(_ for _ in ()).throw(SystemExit("Hermes sys.path selects another checkout")) for p in sys.path if "hermes-agent" in p and pathlib.Path(p).resolve()!=runtime and runtime not in pathlib.Path(p).resolve().parents]' + & $python -c $probe $RuntimeDir + if ($LASTEXITCODE -ne 0) { Fail-Provenance 'Python modules or sys.path are outside the governed checkout' } } -$version = Read-VersionFile -Path $versionFile -$requiredVersionKeys = @('HERMES_COMMIT', 'HERMES_TAG', 'HERMES_INSTALL_PS1_SHA256') -foreach ($key in $requiredVersionKeys) { - if (-not $version.ContainsKey($key)) { throw "Missing key in hermes-version.env: $key" } + +function Invoke-Hermes([string[]]$Arguments) { + & $script:HermesCommand @Arguments + if ($LASTEXITCODE -ne 0) { throw "Hermes command failed: $($Arguments -join ' ')" } } -Write-Host "MiniCISO: Hermes $($version.HERMES_TAG) ($($version.HERMES_COMMIT))" +$version = Read-VersionFile $versionFile +foreach ($key in @('HERMES_REPOSITORY', 'HERMES_COMMIT', 'HERMES_INSTALL_PS1_SHA256')) { + if (-not $version.ContainsKey($key)) { throw "Missing key in hermes-version.env: $key" } +} +$runtimeDir = Join-Path $HermesHome 'hermes-agent' +$pythonCommand = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Select-Object -First 1).Source +if (-not $pythonCommand) { throw 'python3/python/py not found.' } +if ($ForceHermesReinstall -and (Test-Path -LiteralPath $runtimeDir)) { Remove-Item -LiteralPath $runtimeDir -Recurse -Force } if (-not $SkipHermesInstall) { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $installerUri = "https://raw.githubusercontent.com/NousResearch/hermes-agent/$($version.HERMES_COMMIT)/scripts/install.ps1" + $trimmed = $version.HERMES_REPOSITORY -replace '\.git$', '' + if ($trimmed -notmatch '^https://github\.com/([^/]+)/([^/]+)$') { throw 'Unsupported Hermes repository URL.' } + $installerUri = "https://raw.githubusercontent.com/$($matches[1])/$($matches[2])/$($version.HERMES_COMMIT)/scripts/install.ps1" $installerPath = Join-Path ([IO.Path]::GetTempPath()) "hermes-install-$($version.HERMES_COMMIT).ps1" try { Invoke-WebRequest -UseBasicParsing -Uri $installerUri -OutFile $installerPath - $actualHash = (Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash - if ($actualHash -ne $version.HERMES_INSTALL_PS1_SHA256) { - throw "Invalid checksum for the Hermes installer. Expected $($version.HERMES_INSTALL_PS1_SHA256), got $actualHash" - } - - $installDir = Join-Path $HermesHome 'hermes-agent' - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath ` - -Commit $version.HERMES_COMMIT ` - -HermesHome $HermesHome ` - -InstallDir $installDir ` - -SkipSetup ` - -NonInteractive - if ($LASTEXITCODE -ne 0) { throw "Hermes installer exited with code $LASTEXITCODE" } - } - finally { - Remove-Item -LiteralPath $installerPath -Force -ErrorAction SilentlyContinue - } + if ((Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash -ne $version.HERMES_INSTALL_PS1_SHA256) { throw 'Invalid Hermes installer checksum.' } + Prepare-Checkout $runtimeDir $version.HERMES_REPOSITORY $version.HERMES_COMMIT + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath -Commit $version.HERMES_COMMIT -ForceCommit -HermesHome $HermesHome -InstallDir $runtimeDir -SkipSetup -NonInteractive + if ($LASTEXITCODE -ne 0) { throw 'Hermes installer failed.' } + } finally { Remove-Item -LiteralPath $installerPath -Force -ErrorAction SilentlyContinue } } -$env:Path = "$(Join-Path $HermesHome 'bin');$(Join-Path $HOME '.local\bin');$env:Path" -$script:HermesCommand = Resolve-HermesCommand - -if (-not $SkipProviderSetup) { - Write-Host 'Configure the local provider/model. No credentials will be written to the repo.' - Invoke-Hermes -Arguments @('setup') -} +Verify-Provenance $runtimeDir $version.HERMES_REPOSITORY $version.HERMES_COMMIT +$script:HermesCommand = Join-Path $runtimeDir 'venv\Scripts\hermes.exe' +if (-not $SkipProviderSetup) { Invoke-Hermes @('setup') } $profileRoot = Join-Path $HermesHome 'profiles' -$profiles = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'profiles') -Directory | Sort-Object Name +$profiles = @(Get-ChildItem -LiteralPath (Join-Path $repoRoot 'profiles') -Directory | Sort-Object Name) if ($profiles.Count -ne 9) { throw "Expected 9 profiles; found $($profiles.Count)." } - foreach ($profile in $profiles) { - $name = $profile.Name - $destinationDir = Join-Path $profileRoot $name - if (-not (Test-Path -LiteralPath $destinationDir)) { - Write-Host "Creating profile $name" - Invoke-Hermes -Arguments @('profile', 'create', $name, '--clone') - } - + $destinationDir = Join-Path $profileRoot $profile.Name + if (-not (Test-Path -LiteralPath $destinationDir)) { Invoke-Hermes @('profile', 'create', $profile.Name, '--clone') } New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null $sourceSoul = Join-Path $profile.FullName 'SOUL.md' $destinationSoul = Join-Path $destinationDir 'SOUL.md' - if ((Test-Path -LiteralPath $destinationSoul) -and - ((Get-FileHash $sourceSoul).Hash -ne (Get-FileHash $destinationSoul).Hash)) { - Copy-Item -LiteralPath $destinationSoul -Destination "$destinationSoul.pre-miniciso" -Force - } - Copy-Item -LiteralPath $sourceSoul -Destination $destinationSoul -Force + if ((Test-Path -LiteralPath $destinationSoul) -and ((Get-FileHash $sourceSoul).Hash -ne (Get-FileHash $destinationSoul).Hash)) { Copy-Item $destinationSoul "$destinationSoul.pre-miniciso" -Force } + Copy-Item $sourceSoul $destinationSoul -Force } -$skillsSource = Join-Path $repoRoot 'skills' -if (Test-Path -LiteralPath $skillsSource) { - $chiefSkillsRoot = Join-Path $profileRoot 'chief-of-staff\skills' - New-Item -ItemType Directory -Path $chiefSkillsRoot -Force | Out-Null - Get-ChildItem -LiteralPath $skillsSource -Recurse -File | ForEach-Object { - $relative = $_.FullName.Substring($skillsSource.Length + 1) - $destination = Join-Path $chiefSkillsRoot $relative - New-Item -ItemType Directory -Path ([IO.Path]::GetDirectoryName($destination)) -Force | Out-Null - Copy-Item -LiteralPath $_.FullName -Destination $destination -Force - } -} - -foreach ($directory in @('inputs', 'drafts', 'qa', 'reports', 'templates')) { - New-Item -ItemType Directory -Path (Join-Path $WorkspaceRoot $directory) -Force | Out-Null -} -Get-ChildItem -LiteralPath (Join-Path $repoRoot 'templates') -File | - Copy-Item -Destination (Join-Path $WorkspaceRoot 'templates') -Force - -foreach ($profile in $profiles) { - Invoke-Hermes -Arguments @('-p', $profile.Name, 'config', 'set', 'terminal.backend', 'local') - Invoke-Hermes -Arguments @('-p', $profile.Name, 'config', 'set', 'terminal.cwd', $WorkspaceRoot) +$chiefSkillsRoot = Join-Path $profileRoot 'chief-of-staff\skills' +Get-ChildItem -LiteralPath (Join-Path $repoRoot 'skills') -Recurse -File | ForEach-Object { + $relative = $_.FullName.Substring((Join-Path $repoRoot 'skills').Length + 1) + $destination = Join-Path $chiefSkillsRoot $relative + New-Item -ItemType Directory -Path ([IO.Path]::GetDirectoryName($destination)) -Force | Out-Null + Copy-Item $_.FullName $destination -Force } +& $pythonCommand (Join-Path $repoRoot 'scripts\install_governance_config.py') --source (Join-Path $repoRoot 'config\chief-of-staff.public.yaml') --profile-config (Join-Path $profileRoot 'chief-of-staff\config.yaml') +if ($LASTEXITCODE -ne 0) { throw 'Governance configuration installation failed.' } +foreach ($directory in @('inputs', 'drafts', 'qa', 'reports', 'templates')) { New-Item -ItemType Directory -Path (Join-Path $WorkspaceRoot $directory) -Force | Out-Null } +Get-ChildItem -LiteralPath (Join-Path $repoRoot 'templates') -File | Copy-Item -Destination (Join-Path $WorkspaceRoot 'templates') -Force +foreach ($profile in $profiles) { Invoke-Hermes @('-p', $profile.Name, 'config', 'set', 'terminal.backend', 'local'); Invoke-Hermes @('-p', $profile.Name, 'config', 'set', 'terminal.cwd', $WorkspaceRoot) } & (Join-Path $PSScriptRoot 'validate-repo.ps1') - -Write-Host '' -Write-Host 'MiniCISO restored. Run scripts\smoke-test.ps1 to validate the runtime.' -ForegroundColor Green +Write-Host 'MiniCISO restored. Run scripts\smoke-test.ps1 to validate the runtime.' \ No newline at end of file diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 773b580..1ac39ab 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -8,20 +8,100 @@ HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" WORKSPACE_ROOT="${MINICISO_WORKSPACE_ROOT:-$HOME/miniciso-security}" SKIP_HERMES_INSTALL=false SKIP_PROVIDER_SETUP=false +FORCE_HERMES_REINSTALL=false usage() { cat <<'EOF' -Usage: bootstrap.sh [--skip-hermes-install] [--skip-provider-setup] +Usage: bootstrap.sh [--skip-hermes-install] [--skip-provider-setup] [--force-hermes-reinstall] -Installs the pinned Hermes runtime, configures a local provider, and restores -all MiniCISO profiles. Secrets remain in the local Hermes home. +Installs Hermes from the configured repository at the exact configured SHA, +then restores the MiniCISO overlay. Secrets remain in the local Hermes home. EOF } +resolve_python() { + command -v python3 2>/dev/null || command -v python 2>/dev/null || { + echo 'python3/python not found' >&2 + return 1 + } +} + +canonical_repo() { + local value="${1%.git}" + if [[ "$value" =~ ^https://github\.com/([^/]+)/([^/]+)$ ]]; then + printf '%s/%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + elif [[ "$value" =~ ^git@github\.com:([^/]+)/([^/]+)$ ]]; then + printf '%s/%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" + else + echo 'Unsupported Hermes repository URL.' >&2 + return 1 + fi +} + +fail_provenance() { + echo "Hermes provenance check failed: $1" >&2 + return 1 +} + +prepare_checkout() { + local runtime_dir="$1" + local expected_repo="$2" + local expected_commit="$3" + local expected_owner_repo + expected_owner_repo="$(canonical_repo "$expected_repo")" + + if [[ ! -d "$runtime_dir/.git" ]]; then + mkdir -p "$runtime_dir" + git -C "$runtime_dir" init -q + git -C "$runtime_dir" remote add origin "$expected_repo" + fi + git -C "$runtime_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || fail_provenance 'checkout is not a Git repository' + local origin + origin="$(git -C "$runtime_dir" config --get remote.origin.url || true)" + [[ -n "$origin" ]] || fail_provenance 'origin is missing' + [[ "$(canonical_repo "$origin")" == "$expected_owner_repo" ]] || fail_provenance 'origin does not match configured repository' + if ! git -C "$runtime_dir" cat-file -e "$expected_commit^{commit}" 2>/dev/null; then + git -C "$runtime_dir" fetch --no-tags --depth=1 origin "$expected_commit" + fi + git -C "$runtime_dir" checkout --detach "$expected_commit" >/dev/null +} + +verify_provenance() { + local runtime_dir="$1" + local expected_repo="$2" + local expected_commit="$3" + local expected_owner_repo + expected_owner_repo="$(canonical_repo "$expected_repo")" + git -C "$runtime_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || fail_provenance 'checkout is not a Git repository' + [[ "$(git -C "$runtime_dir" rev-parse HEAD)" == "$expected_commit" ]] || fail_provenance 'HEAD does not match configured commit' + [[ "$(canonical_repo "$(git -C "$runtime_dir" config --get remote.origin.url)")" == "$expected_owner_repo" ]] || fail_provenance 'origin does not match configured repository' + local hermes_cmd="$runtime_dir/venv/bin/hermes" + local hermes_python="$runtime_dir/venv/bin/python" + [[ -x "$hermes_cmd" ]] || fail_provenance 'Hermes executable is outside the governed checkout or missing' + [[ -x "$hermes_python" ]] || fail_provenance 'Hermes Python is outside the governed checkout or missing' + "$hermes_python" - "$runtime_dir" <<'PY' +import importlib +import pathlib +import sys + +runtime = pathlib.Path(sys.argv[1]).resolve() +for name in ("hermes_cli", "hermes_cli.config", "hermes_constants"): + module = importlib.import_module(name) + path = pathlib.Path(module.__file__).resolve() + if runtime not in path.parents: + raise SystemExit(f"Hermes module outside governed checkout: {name}") +for entry in sys.path: + entry_path = pathlib.Path(entry).resolve() + if "hermes-agent" in entry and entry_path != runtime and runtime not in entry_path.parents: + raise SystemExit("Hermes sys.path selects another checkout") +PY +} + while [[ $# -gt 0 ]]; do case "$1" in --skip-hermes-install) SKIP_HERMES_INSTALL=true ;; --skip-provider-setup) SKIP_PROVIDER_SETUP=true ;; + --force-hermes-reinstall) FORCE_HERMES_REINSTALL=true ;; -h|--help) usage; exit 0 ;; *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; esac @@ -30,46 +110,38 @@ done # shellcheck disable=SC1090 source "$VERSION_FILE" +: "${HERMES_REPOSITORY:?missing HERMES_REPOSITORY}" : "${HERMES_COMMIT:?missing HERMES_COMMIT}" -: "${HERMES_TAG:?missing HERMES_TAG}" : "${HERMES_INSTALL_SH_SHA256:?missing HERMES_INSTALL_SH_SHA256}" -echo "MiniCISO: Hermes $HERMES_TAG ($HERMES_COMMIT)" +export HERMES_HOME +PYTHON_BIN="$(resolve_python)" +runtime_dir="$HERMES_HOME/hermes-agent" + +if [[ "$FORCE_HERMES_REINSTALL" == true && -e "$runtime_dir" ]]; then + rm -rf "$runtime_dir" +fi if [[ "$SKIP_HERMES_INSTALL" == false ]]; then installer="$(mktemp)" trap 'rm -f "$installer"' EXIT - curl --fail --silent --show-error --location \ - "https://raw.githubusercontent.com/NousResearch/hermes-agent/$HERMES_COMMIT/scripts/install.sh" \ - --output "$installer" - - if command -v sha256sum >/dev/null 2>&1; then - actual_hash="$(sha256sum "$installer" | awk '{print toupper($1)}')" - else - actual_hash="$(shasum -a 256 "$installer" | awk '{print toupper($1)}')" - fi - if [[ "$actual_hash" != "$HERMES_INSTALL_SH_SHA256" ]]; then - echo "Invalid Hermes installer checksum: $actual_hash" >&2 - exit 1 - fi - - bash "$installer" \ - --commit "$HERMES_COMMIT" \ - --hermes-home "$HERMES_HOME" \ - --dir "$HERMES_HOME/hermes-agent" \ - --skip-setup \ - --non-interactive + repo_path="${HERMES_REPOSITORY%.git}" + [[ "$repo_path" =~ ^https://github\.com/([^/]+)/([^/]+)$ ]] || { echo 'Unsupported Hermes repository URL.' >&2; exit 1; } + raw_base="https://raw.githubusercontent.com/${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + curl --fail --silent --show-error --location "$raw_base/$HERMES_COMMIT/scripts/install.sh" --output "$installer" + actual_hash="$(sha256sum "$installer" | awk '{print toupper($1)}')" + [[ "$actual_hash" == "$HERMES_INSTALL_SH_SHA256" ]] || { echo 'Invalid Hermes installer checksum.' >&2; exit 1; } + prepare_checkout "$runtime_dir" "$HERMES_REPOSITORY" "$HERMES_COMMIT" + bash "$installer" --commit "$HERMES_COMMIT" --force-commit --hermes-home "$HERMES_HOME" --dir "$runtime_dir" --skip-setup --non-interactive fi -export PATH="$HERMES_HOME/bin:$HOME/.local/bin:$PATH" -if ! command -v hermes >/dev/null 2>&1; then - echo 'hermes command not found. Open a new shell and rerun with --skip-hermes-install.' >&2 - exit 1 -fi +# This is deliberately before any profile, skill, config, or template write. +verify_provenance "$runtime_dir" "$HERMES_REPOSITORY" "$HERMES_COMMIT" +hermes_cmd="$runtime_dir/venv/bin/hermes" if [[ "$SKIP_PROVIDER_SETUP" == false ]]; then echo 'Configure the local provider/model. No credentials are written to this repo.' - hermes setup + "$hermes_cmd" setup fi profile_root="$HERMES_HOME/profiles" @@ -85,8 +157,7 @@ fi for profile in "${profiles[@]}"; do destination_dir="$profile_root/$profile" if [[ ! -d "$destination_dir" ]]; then - echo "Creating profile $profile" - hermes profile create "$profile" --clone + "$hermes_cmd" profile create "$profile" --clone fi mkdir -p "$destination_dir" source_soul="$REPO_ROOT/profiles/$profile/SOUL.md" @@ -98,25 +169,25 @@ for profile in "${profiles[@]}"; do done skills_source="$REPO_ROOT/skills" -if [[ -d "$skills_source" ]]; then - chief_skills_root="$profile_root/chief-of-staff/skills" - mkdir -p "$chief_skills_root" - while IFS= read -r -d '' skill_file; do - rel_path="${skill_file#"$skills_source/"}" - destination_skill="$chief_skills_root/$rel_path" - mkdir -p "$(dirname "$destination_skill")" - cp "$skill_file" "$destination_skill" - done < <(find "$skills_source" -type f -print0) -fi +chief_skills_root="$profile_root/chief-of-staff/skills" +mkdir -p "$chief_skills_root" +while IFS= read -r -d '' skill_file; do + rel_path="${skill_file#"$skills_source/"}" + destination_skill="$chief_skills_root/$rel_path" + mkdir -p "$(dirname "$destination_skill")" + cp "$skill_file" "$destination_skill" +done < <(find "$skills_source" -type f -print0) + +"$PYTHON_BIN" "$SCRIPT_DIR/install_governance_config.py" \ + --source "$REPO_ROOT/config/chief-of-staff.public.yaml" \ + --profile-config "$profile_root/chief-of-staff/config.yaml" mkdir -p "$WORKSPACE_ROOT"/{inputs,drafts,qa,reports,templates} cp "$REPO_ROOT"/templates/* "$WORKSPACE_ROOT/templates/" - for profile in "${profiles[@]}"; do - hermes -p "$profile" config set terminal.backend local - hermes -p "$profile" config set terminal.cwd "$WORKSPACE_ROOT" + "$hermes_cmd" -p "$profile" config set terminal.backend local + "$hermes_cmd" -p "$profile" config set terminal.cwd "$WORKSPACE_ROOT" done "$SCRIPT_DIR/validate-repo.sh" -echo -echo 'MiniCISO restored. Run scripts/smoke-test.sh to validate the runtime.' +echo 'MiniCISO restored. Run scripts/smoke-test.sh to validate the runtime.' \ No newline at end of file diff --git a/scripts/check_bundled_capabilities.py b/scripts/check_bundled_capabilities.py index 7c6a4d6..33064fc 100644 --- a/scripts/check_bundled_capabilities.py +++ b/scripts/check_bundled_capabilities.py @@ -5,12 +5,19 @@ from pathlib import Path REQUIRED_SKILLS = [ + "cost-context-governance", "miniciso-kag-finding-gate", "miniciso-headroom-phase1", "miniciso-institutional-learning", ] +def _skill_path(repo_root: Path, skill: str) -> Path: + if skill.startswith("miniciso-"): + return repo_root / "skills" / "security" / skill / "SKILL.md" + return repo_root / "skills" / skill / "SKILL.md" + + def _require_contains(path: Path, needle: str, message: str) -> None: text = path.read_text(encoding="utf-8") if needle not in text: @@ -21,7 +28,7 @@ def validate_repo(repo_root: Path) -> None: repo_root = Path(repo_root) for skill in REQUIRED_SKILLS: - skill_path = repo_root / "skills" / "security" / skill / "SKILL.md" + skill_path = _skill_path(repo_root, skill) if not skill_path.is_file(): raise ValueError(f"missing bundled skill file: {skill_path}") diff --git a/scripts/install_governance_config.py b/scripts/install_governance_config.py new file mode 100644 index 0000000..1c65152 --- /dev/null +++ b/scripts/install_governance_config.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import tempfile +from pathlib import Path + +try: + import yaml +except ModuleNotFoundError as exc: + raise SystemExit("PyYAML is required; run this with the Hermes environment Python.") from exc + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Merge the public governance config into installed Hermes profiles.") + parser.add_argument("--source", required=True, help="Path to config/chief-of-staff.public.yaml") + parser.add_argument("--profile-config", action="append", required=True, help="Target profile config.yaml path") + return parser.parse_args() + + +def load_yaml(path: Path) -> dict: + if not path.exists(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + + +def dump_yaml(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = yaml.safe_dump(data, sort_keys=False, allow_unicode=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent, text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except Exception: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +def merge_governance(source: dict, target: dict) -> dict: + merged = dict(target) + merged["cost_context_governance"] = source.get("cost_context_governance", {}) + return merged + + +def main() -> int: + args = parse_args() + source = load_yaml(Path(args.source).resolve()) + if "cost_context_governance" not in source: + raise SystemExit("Source config does not contain cost_context_governance") + + results = [] + for raw_path in args.profile_config: + path = Path(raw_path).resolve() + if path.parent.name != "chief-of-staff": + raise SystemExit(f"Refusing governance merge outside chief-of-staff profile: {path.name}") + current = load_yaml(path) + merged = merge_governance(source, current) + dump_yaml(path, merged) + results.append({ + "profile_config": str(path), + "mode": merged["cost_context_governance"].get("mode"), + "workspace_dir": merged["cost_context_governance"].get("workspace_dir"), + }) + + print(json.dumps({"updated": results}, indent=2, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index 8b616fd..4e05305 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -1,28 +1,31 @@ [CmdletBinding()] -param([switch]$Online) - +param( + [switch]$Online, + [string]$HermesHome = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { Join-Path $env:LOCALAPPDATA 'hermes' }) +) $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$hermesHome = if ($env:HERMES_HOME) { $env:HERMES_HOME } else { Join-Path $HOME '.hermes' } -$profiles = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'profiles') -Directory | Sort-Object Name -$hermes = Get-Command hermes -ErrorAction Stop -$profileList = (& $hermes.Source profile list 2>&1 | Out-String) -if ($LASTEXITCODE -ne 0) { throw 'hermes profile list failed.' } - -foreach ($profile in $profiles) { - if ($profileList -notmatch [regex]::Escape($profile.Name)) { - throw "Profile not registered in Hermes: $($profile.Name)" - } - $installedSoul = Join-Path $hermesHome "profiles\$($profile.Name)\SOUL.md" - if (-not (Test-Path -LiteralPath $installedSoul)) { - throw "SOUL.md not installed: $($profile.Name)" - } - Write-Host "OK: $($profile.Name)" - - if ($Online) { - & $hermes.Source -p $profile.Name chat -Q -q 'Answer in one line starting with OK and state your role in MiniCISO.' - if ($LASTEXITCODE -ne 0) { throw "Online smoke test failed: $($profile.Name)" } - } -} - -Write-Host "Smoke test completed for $($profiles.Count) profiles." -ForegroundColor Green +$env:HERMES_HOME = $HermesHome +$version = @{} +foreach ($line in Get-Content (Join-Path $repoRoot 'config\hermes-version.env')) { if ($line -match '^([A-Z0-9_]+)=(.+)$') { $version[$matches[1]] = $matches[2] } } +function Canonical-Repo([string]$Value) { $v = $Value -replace '\.git$', ''; if ($v -match '^https://github\.com/([^/]+)/([^/]+)$') { return "$($matches[1])/$($matches[2])" }; if ($v -match '^git@github\.com:([^/]+)/([^/]+)$') { return "$($matches[1])/$($matches[2])" }; throw 'Unsupported Hermes repository URL.' } +if ($version.HERMES_REPOSITORY -ne 'https://github.com/icidade/hermes-agent.git') { throw 'Unexpected Hermes repository.' } +if ($version.HERMES_COMMIT -ne '489c6f2103ccca0ac1fc4f6249c71924ec8f024c') { throw 'Unexpected Hermes commit.' } +$runtime = Join-Path $HermesHome 'hermes-agent' +$head = (& git -C $runtime rev-parse HEAD).Trim() +if ($head -ne $version.HERMES_COMMIT) { throw 'Hermes HEAD mismatch.' } +$origin = (& git -C $runtime config --get remote.origin.url).Trim() +if ((Canonical-Repo $origin) -ne (Canonical-Repo $version.HERMES_REPOSITORY)) { throw 'Hermes origin mismatch.' } +$hermes = Join-Path $runtime 'venv\Scripts\hermes.exe' +$python = Join-Path $runtime 'venv\Scripts\python.exe' +if (-not (Test-Path $hermes -PathType Leaf) -or -not (Test-Path $python -PathType Leaf)) { throw 'Hermes executable or Python missing from checkout.' } +$profiles = @(Get-ChildItem (Join-Path $repoRoot 'profiles') -Directory | Sort-Object Name | ForEach-Object Name) +$installed = @(& $hermes profile list | ForEach-Object { ($_ -split '\s+')[0] } | Where-Object { $_ } | Sort-Object -Unique) +if ((Compare-Object $profiles $installed)) { throw 'Hermes profiles do not match exactly.' } +foreach ($profile in $profiles) { $root = Join-Path $HermesHome "profiles\$profile"; if (-not (Test-Path (Join-Path $root 'SOUL.md')) -or -not (Test-Path (Join-Path $root 'config.yaml'))) { throw "Profile files missing: $profile" } } +$chief = Join-Path $HermesHome 'profiles\chief-of-staff' +if (-not (Test-Path (Join-Path $chief 'skills\cost-context-governance\SKILL.md'))) { throw 'Governance skill missing.' } +if (-not (Select-String (Join-Path $chief 'SOUL.md') 'cost-context-governance' -Quiet)) { throw 'Chief SOUL missing governance reference.' } +if (-not (Select-String (Join-Path $chief 'config.yaml') 'mode: enforce' -Quiet)) { throw 'Chief governance mode is not enforce.' } +if ($Online) { foreach ($profile in $profiles) { & $hermes -p $profile chat -Q -q 'Answer in one line starting with OK.' } } +Write-Host "Smoke test completed for $($profiles.Count) exact profiles." \ No newline at end of file diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index cb9f2f1..ebda6e6 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1,25 +1,48 @@ #!/usr/bin/env bash set -euo pipefail - SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" ONLINE=false [[ ${1:-} == '--online' ]] && ONLINE=true +[[ ${1:-} == '--hermes-home' ]] && { HERMES_HOME="$2"; shift 2; } +[[ ${1:-} == '--online' ]] && ONLINE=true -command -v hermes >/dev/null 2>&1 || { echo 'hermes command not found' >&2; exit 1; } -profile_list="$(hermes profile list)" -profiles=() -for profile_dir in "$REPO_ROOT"/profiles/*; do - [[ -d "$profile_dir" ]] && profiles+=("${profile_dir##*/}") -done - -for profile in "${profiles[@]}"; do - grep -Fq "$profile" <<<"$profile_list" || { echo "Profile not registered: $profile" >&2; exit 1; } - [[ -f "${HERMES_HOME:-$HOME/.hermes}/profiles/$profile/SOUL.md" ]] || { echo "SOUL.md not installed: $profile" >&2; exit 1; } - echo "OK: $profile" - if [[ "$ONLINE" == true ]]; then - hermes -p "$profile" chat -Q -q 'Answer in one line starting with OK and state your role in MiniCISO.' - fi +expected_repository="$(awk -F= '$1=="HERMES_REPOSITORY" {print $2}' "$REPO_ROOT/config/hermes-version.env")" +expected_commit="$(awk -F= '$1=="HERMES_COMMIT" {print $2}' "$REPO_ROOT/config/hermes-version.env")" +canonical_repo() { local value="${1%.git}"; [[ "$value" =~ ^https://github\.com/([^/]+)/([^/]+)$ ]] && printf '%s/%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" || [[ "$value" =~ ^git@github\.com:([^/]+)/([^/]+)$ ]] && printf '%s/%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" || return 1; } +runtime="$HERMES_HOME/hermes-agent" +[[ "$expected_repository" == 'https://github.com/icidade/hermes-agent.git' ]] || { echo 'Unexpected Hermes repository.' >&2; exit 1; } +[[ "$expected_commit" == '489c6f2103ccca0ac1fc4f6249c71924ec8f024c' ]] || { echo 'Unexpected Hermes commit.' >&2; exit 1; } +[[ -d "$runtime/.git" ]] || { echo 'Hermes checkout missing.' >&2; exit 1; } +[[ "$(git -C "$runtime" rev-parse HEAD)" == "$expected_commit" ]] || { echo 'Hermes HEAD mismatch.' >&2; exit 1; } +[[ "$(canonical_repo "$(git -C "$runtime" config --get remote.origin.url)")" == "$(canonical_repo "$expected_repository")" ]] || { echo 'Hermes origin mismatch.' >&2; exit 1; } +hermes="$runtime/venv/bin/hermes" +python="$runtime/venv/bin/python" +[[ -x "$hermes" && -x "$python" ]] || { echo 'Hermes executable or Python missing from checkout.' >&2; exit 1; } +"$python" - "$runtime" <<'PY' +import importlib, pathlib, sys +runtime = pathlib.Path(sys.argv[1]).resolve() +for name in ('hermes_cli', 'hermes_cli.config', 'hermes_constants'): + if runtime not in pathlib.Path(importlib.import_module(name).__file__).resolve().parents: + raise SystemExit('Hermes module provenance mismatch') +for path in sys.path: + path_obj = pathlib.Path(path).resolve() + if 'hermes-agent' in path and path_obj != runtime and runtime not in path_obj.parents: + raise SystemExit('Hermes sys.path provenance mismatch') +PY +mapfile -t expected_profiles < <(find "$REPO_ROOT/profiles" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) +mapfile -t installed_profiles < <("$hermes" profile list | awk 'NF {print $1}' | sort -u) +[[ "${expected_profiles[*]}" == "${installed_profiles[*]}" ]] || { echo 'Hermes profiles do not match exactly.' >&2; exit 1; } +for profile in "${expected_profiles[@]}"; do + root="$HERMES_HOME/profiles/$profile" + [[ -f "$root/SOUL.md" && -f "$root/config.yaml" ]] || { echo "Profile files missing: $profile" >&2; exit 1; } done - -echo "Smoke test completed for ${#profiles[@]} profiles." +chief="$HERMES_HOME/profiles/chief-of-staff" +[[ -f "$chief/skills/cost-context-governance/SKILL.md" ]] || { echo 'Governance skill missing.' >&2; exit 1; } +grep -Fq 'cost-context-governance' "$chief/SOUL.md" || { echo 'Chief SOUL missing governance reference.' >&2; exit 1; } +grep -Fq 'mode: enforce' "$chief/config.yaml" || { echo 'Chief governance mode is not enforce.' >&2; exit 1; } +if [[ "$ONLINE" == true ]]; then + for profile in "${expected_profiles[@]}"; do "$hermes" -p "$profile" chat -Q -q 'Answer in one line starting with OK.'; done +fi +echo "Smoke test completed for ${#expected_profiles[@]} exact profiles." \ No newline at end of file diff --git a/scripts/tests/test_check_bundled_capabilities.py b/scripts/tests/test_check_bundled_capabilities.py index 94ae7ca..660ec26 100644 --- a/scripts/tests/test_check_bundled_capabilities.py +++ b/scripts/tests/test_check_bundled_capabilities.py @@ -7,6 +7,7 @@ REQUIRED_SKILLS = [ + "cost-context-governance", "miniciso-kag-finding-gate", "miniciso-headroom-phase1", "miniciso-institutional-learning", @@ -17,7 +18,10 @@ class BundledCapabilitiesValidationTests(unittest.TestCase): def make_repo(self) -> Path: root = Path(tempfile.mkdtemp(prefix="miniciso-capabilities-")) for skill in REQUIRED_SKILLS: - skill_dir = root / "skills" / "security" / skill + if skill.startswith("miniciso-"): + skill_dir = root / "skills" / "security" / skill + else: + skill_dir = root / "skills" / skill skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text( f"---\nname: {skill}\ndescription: test\n---\n\n# {skill}\n", @@ -29,6 +33,7 @@ def make_repo(self) -> Path: "\n".join( [ "# SOUL", + "cost-context-governance", "miniciso-kag-finding-gate", "miniciso-headroom-phase1", "miniciso-institutional-learning", @@ -55,10 +60,7 @@ def make_repo(self) -> Path: meta_dir = root / "meta" meta_dir.mkdir(parents=True, exist_ok=True) - (meta_dir / "MANIFEST.json").write_text( - json.dumps({"bundled_skills": REQUIRED_SKILLS}, indent=2), - encoding="utf-8", - ) + (meta_dir / "MANIFEST.json").write_text(json.dumps({"bundled_skills": REQUIRED_SKILLS}, indent=2), encoding="utf-8") return root def test_validate_repo_accepts_expected_layout(self): diff --git a/scripts/tests/test_runtime_pin_bootstrap_config.py b/scripts/tests/test_runtime_pin_bootstrap_config.py new file mode 100644 index 0000000..35000f9 --- /dev/null +++ b/scripts/tests/test_runtime_pin_bootstrap_config.py @@ -0,0 +1,155 @@ +import json +import os +import subprocess +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +EXPECTED_REPOSITORY = "https://github.com/icidade/hermes-agent.git" +EXPECTED_COMMIT = "489c6f2103ccca0ac1fc4f6249c71924ec8f024c" + + +def parse_env_file(path: Path) -> dict[str, str]: + values = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + values[key] = value + return values + + +def test_runtime_pin_is_the_fork_sha_and_has_installer_hashes(): + values = parse_env_file(REPO_ROOT / "config/hermes-version.env") + assert values["HERMES_REPOSITORY"] == EXPECTED_REPOSITORY + assert values["HERMES_COMMIT"] == EXPECTED_COMMIT + assert len(values["HERMES_INSTALL_SH_SHA256"]) == 64 + assert len(values["HERMES_INSTALL_PS1_SHA256"]) == 64 + + +class RuntimePinBootstrapConfigTests(unittest.TestCase): + def test_runtime_pin_is_the_fork_sha_and_has_installer_hashes(self): + values = parse_env_file(REPO_ROOT / "config/hermes-version.env") + self.assertEqual(values["HERMES_REPOSITORY"], EXPECTED_REPOSITORY) + self.assertEqual(values["HERMES_COMMIT"], EXPECTED_COMMIT) + self.assertEqual(len(values["HERMES_INSTALL_SH_SHA256"]), 64) + self.assertEqual(len(values["HERMES_INSTALL_PS1_SHA256"]), 64) + + def test_bootstraps_have_no_obsolete_patchset_or_global_fallback(self): + for bootstrap in ("scripts/bootstrap.sh", "scripts/bootstrap.ps1"): + with self.subTest(bootstrap=bootstrap): + text = (REPO_ROOT / bootstrap).read_text(encoding="utf-8-sig") + self.assertNotIn("apply_hermes_patchset", text) + self.assertNotIn(".miniciso-governance-patch.json", text) + self.assertNotIn("git apply", text) + self.assertNotIn("NousResearch/hermes-agent", text) + self.assertNotIn("command -v hermes", text) + self.assertNotIn("Get-Command hermes", text) + + + def test_governance_merge_is_idempotent_and_preserves_keys(self): + import tempfile + + with tempfile.TemporaryDirectory() as directory: + tmp_path = Path(directory) + source = tmp_path / "source.yaml" + target = tmp_path / "chief-of-staff" / "config.yaml" + target.parent.mkdir() + source.write_text( + "cost_context_governance:\n mode: enforce\n workspace_dir: engagements\n", + encoding="utf-8", + ) + target.write_text( + "model:\n name: local\ncustom_key: preserved\n", + encoding="utf-8", + ) + + script = REPO_ROOT / "scripts/install_governance_config.py" + command = [ + "python3", + str(script), + "--source", + str(source), + "--profile-config", + str(target), + ] + first = subprocess.run(command, check=True, capture_output=True, text=True) + first_bytes = target.read_bytes() + second = subprocess.run(command, check=True, capture_output=True, text=True) + + self.assertTrue(first.stdout) + self.assertTrue(second.stdout) + self.assertEqual(target.read_bytes(), first_bytes) + text = target.read_text(encoding="utf-8") + self.assertIn("custom_key: preserved", text) + self.assertIn("mode: enforce", text) + + + def test_governance_merge_does_not_touch_other_profiles(self): + import tempfile + + with tempfile.TemporaryDirectory() as directory: + tmp_path = Path(directory) + chief = tmp_path / "chief-of-staff" / "config.yaml" + chief.parent.mkdir() + other = tmp_path / "other-profile" / "config.yaml" + other.parent.mkdir() + chief.write_text("existing: chief\n", encoding="utf-8") + other.write_text("existing: other\n", encoding="utf-8") + source = tmp_path / "source.yaml" + source.write_text("cost_context_governance:\n mode: enforce\n", encoding="utf-8") + + subprocess.run( + [ + "python3", + str(REPO_ROOT / "scripts/install_governance_config.py"), + "--source", + str(source), + "--profile-config", + str(chief), + ], + check=True, + ) + + self.assertIn("cost_context_governance", chief.read_text(encoding="utf-8")) + self.assertEqual(other.read_text(encoding="utf-8"), "existing: other\n") + + + def test_profile_checks_are_exact_not_substring(self): + for path in (REPO_ROOT / "scripts/smoke-test.sh", REPO_ROOT / "scripts/smoke-test.ps1"): + text = path.read_text(encoding="utf-8-sig") + self.assertIn("profile list", text) + self.assertTrue("exact" in text.lower() or "-eq" in text or "Compare-Object" in text) + + def test_skip_install_rejects_wrong_head_before_overlay_writes(self): + import tempfile + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) / "hermes-home" + runtime = home / "hermes-agent" + runtime.mkdir(parents=True) + subprocess.run(["git", "-C", str(runtime), "init", "-q"], check=True) + (runtime / "marker").write_text("wrong\n", encoding="utf-8") + subprocess.run(["git", "-C", str(runtime), "add", "marker"], check=True) + subprocess.run(["git", "-C", str(runtime), "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "wrong"], check=True) + subprocess.run(["git", "-C", str(runtime), "remote", "add", "origin", EXPECTED_REPOSITORY], check=True) + result = subprocess.run( + ["bash", str(REPO_ROOT / "scripts/bootstrap.sh"), "--skip-hermes-install", "--skip-provider-setup"], + env={**os.environ, "HERMES_HOME": str(home), "MINICISO_WORKSPACE_ROOT": str(Path(directory) / "workspace")}, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("HEAD does not match configured commit", result.stderr) + self.assertFalse((home / "profiles").exists()) + + def test_provenance_contract_rejects_global_executable_and_external_modules(self): + bootstrap = (REPO_ROOT / "scripts/bootstrap.sh").read_text(encoding="utf-8") + self.assertIn("runtime_dir/venv/bin/hermes", bootstrap) + self.assertIn("runtime_dir/venv/bin/python", bootstrap) + self.assertIn("Hermes module outside governed checkout", bootstrap) + self.assertIn("Hermes sys.path selects another checkout", bootstrap) + self.assertIn("origin does not match configured repository", bootstrap) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate-repo.ps1 b/scripts/validate-repo.ps1 index 345b72b..8720b99 100644 --- a/scripts/validate-repo.ps1 +++ b/scripts/validate-repo.ps1 @@ -14,13 +14,18 @@ $requiredFiles = @( 'config/hermes-version.env', 'config/tooling-dependencies.example.yaml', 'config/chief-of-staff.public.yaml', 'scripts/bootstrap.ps1', 'scripts/bootstrap.sh', 'scripts/check_bundled_capabilities.py', + 'scripts/install_governance_config.py', 'scripts/smoke-test.ps1', 'scripts/smoke-test.sh', 'scripts/validate-repo.ps1', 'scripts/validate-repo.sh', 'scripts/tests/test_check_bundled_capabilities.py', + 'scripts/tests/test_runtime_pin_bootstrap_config.py', 'meta/MANIFEST.json', 'meta/SUMMARY.json', + 'skills/cost-context-governance/SKILL.md', 'skills/security/miniciso-kag-finding-gate/SKILL.md', 'skills/security/miniciso-headroom-phase1/SKILL.md', - 'skills/security/miniciso-institutional-learning/SKILL.md' + 'skills/security/miniciso-institutional-learning/SKILL.md', + 'tools/headroom_phase1/execution_output_optimizer.py', + 'tools/headroom_phase1/tests/test_execution_output_optimizer.py' ) foreach ($relative in $requiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $relative))) { @@ -42,7 +47,13 @@ foreach ($profile in $profiles) { $versionPath = Join-Path $repoRoot 'config/hermes-version.env' if (Test-Path -LiteralPath $versionPath) { $versionText = Get-Content -LiteralPath $versionPath -Raw -Encoding UTF8 - if ($versionText -notmatch '(?m)^HERMES_TAG=v\d{4}\.\d{1,2}\.\d{1,2}\r?$') { + if ($versionText -notmatch '(?m)^HERMES_REPOSITORY=https://github\.com/icidade/hermes-agent\.git\r?$') { + Add-ValidationError 'HERMES_REPOSITORY must point to the pinned fork.' + } + if ($versionText -notmatch '(?m)^HERMES_COMMIT=489c6f2103ccca0ac1fc4f6249c71924ec8f024c\r?$') { + Add-ValidationError 'HERMES_COMMIT does not match the release pin.' + } + if ($versionText -notmatch '(?m)^HERMES_TAG=v\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?\r?$') { Add-ValidationError 'HERMES_TAG does not match the expected format.' } if ($versionText -notmatch '(?m)^HERMES_COMMIT=[0-9a-f]{40}\r?$') { diff --git a/scripts/validate-repo.sh b/scripts/validate-repo.sh index df83870..698dd50 100755 --- a/scripts/validate-repo.sh +++ b/scripts/validate-repo.sh @@ -15,13 +15,18 @@ required_files=( config/hermes-version.env config/tooling-dependencies.example.yaml config/chief-of-staff.public.yaml scripts/bootstrap.ps1 scripts/bootstrap.sh scripts/check_bundled_capabilities.py + scripts/install_governance_config.py scripts/smoke-test.ps1 scripts/smoke-test.sh scripts/validate-repo.ps1 scripts/validate-repo.sh scripts/tests/test_check_bundled_capabilities.py + scripts/tests/test_runtime_pin_bootstrap_config.py meta/MANIFEST.json meta/SUMMARY.json + skills/cost-context-governance/SKILL.md skills/security/miniciso-kag-finding-gate/SKILL.md skills/security/miniciso-headroom-phase1/SKILL.md skills/security/miniciso-institutional-learning/SKILL.md + tools/headroom_phase1/execution_output_optimizer.py + tools/headroom_phase1/tests/test_execution_output_optimizer.py ) for file in "${required_files[@]}"; do [[ -f "$REPO_ROOT/$file" ]] || fail "required file missing: $file" @@ -37,7 +42,9 @@ for profile in "${profiles[@]}"; do done version_file="$REPO_ROOT/config/hermes-version.env" -grep -Eq '^HERMES_TAG=v[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}$' "$version_file" || fail 'invalid HERMES_TAG' +grep -Fxq 'HERMES_REPOSITORY=https://github.com/icidade/hermes-agent.git' "$version_file" || fail 'invalid HERMES_REPOSITORY' +grep -Fxq 'HERMES_COMMIT=489c6f2103ccca0ac1fc4f6249c71924ec8f024c' "$version_file" || fail 'unexpected HERMES_COMMIT' +grep -Eq '^HERMES_TAG=v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$' "$version_file" || fail 'invalid HERMES_TAG' grep -Eq '^HERMES_COMMIT=[0-9a-f]{40}$' "$version_file" || fail 'invalid HERMES_COMMIT' [[ $(grep -Ec '^HERMES_INSTALL_(PS1|SH)_SHA256=[A-F0-9]{64}$' "$version_file") -eq 2 ]] || fail 'invalid installer checksums' diff --git a/skills/cost-context-governance/SKILL.md b/skills/cost-context-governance/SKILL.md new file mode 100644 index 0000000..b7d8f00 --- /dev/null +++ b/skills/cost-context-governance/SKILL.md @@ -0,0 +1,204 @@ +--- +name: cost-context-governance +description: Mandatory execution governance for Chief-of-Staff and MiniCISO work. Classifies every request, plans bounded resource use, preserves QA reserve, constrains delegation/tool schemas, and persists resumable partial handoffs. +version: 0.7.0 +author: MiniCISO +license: MIT +metadata: + miniciso: + mandatory_for_profiles: + - chief-of-staff + request_classes: + - conversational + - bounded + - engagement + separates_from: + - KAG + - Headroom + - Institutional Learning + - Security QA +--- + +# Cost & Context Governance + +## Purpose + +This skill is mandatory for the `chief-of-staff` profile on **every** request. + +It keeps simple conversation lightweight while forcing bounded planning and resumable execution for work that can otherwise explode in cost, context size, delegation fan-out, or runtime. + +## Required classification on every request + +Before deep execution, classify the request as exactly one of: + +1. `conversational` +2. `bounded` +3. `engagement` + +### Classification guidance + +- `conversational`: normal chat, quick answer, light triage, no sustained tool loop expected. +- `bounded`: contained implementation, focused review, short investigation, or a task that needs tools/delegation but should stay within a small envelope. +- `engagement`: multi-step assessment, MiniCISO/security work, delegated multi-lane execution, long research, or anything requiring evidence/claim tracking and final QA. + +For `conversational`, do the lightest possible governance pass and continue. + +For `bounded` or `engagement`, governance must become explicit and visible. + +## Mandatory operating sequence + +### 1. Establish the execution shape + +For `bounded` and `engagement` work, define before deep execution: + +- objective +- scope +- exclusions +- chosen budget profile +- expected artifacts +- required roles/SMEs +- QA obligation +- checkpoint / handoff triggers + +### 2. Create isolated state + +Long-running work must not rely on the active chat as full working memory. + +Use isolated task or engagement state and keep the conversational Chief session limited to: + +- user intent +- scope and exclusions +- approvals +- key decisions +- concise progress +- resource state +- artifact references +- final summary + +Do **not** continuously re-inject: + +- raw SME transcripts +- full tool dumps +- complete engagement history +- prior report drafts in full +- every child conversation + +### 3. Choose only necessary roles and tools + +- select only the SMEs actually required by the task +- do not spawn all SMEs by default +- restrict tools by role and task +- send the smallest viable tool/schema surface +- give each child only the context package it needs + +### 4. Preserve Security QA reserve + +A protected QA reserve is mandatory for `bounded` and `engagement` work that will end in validation. + +Rules: + +- reserve budget before exploration starts +- do not let SME or synthesis work silently consume it +- if remaining headroom threatens QA viability, checkpoint and pause/escalate +- final user-facing security claims still require independent `security-qa` + +### 5. Allocate bounded child envelopes + +Delegated children must receive: + +- a bounded child budget +- a bounded child context package +- only the required tools +- a resume identifier / handoff target + +Children must **not** inherit the full root budget by default. + +### 6. Monitor measurable progress + +Track real progress through state change, not optimistic narration. + +Examples of valid progress: + +- evidence added +- claim created or updated +- claim supported / contradicted / rejected / closed +- open question resolved +- expected artifact produced +- required phase completed + +Examples of non-progress that must count against the breaker: + +- repeated equivalent tool calls +- repeated provider or tool failures +- repeated restatement of the same plan +- context growth with no evidence/claim delta +- retry loops without new information + +### 7. Checkpoint before hard stops + +Before likely timeout, budget exhaustion, no-progress break, or operator pause: + +- checkpoint current state +- persist partial work +- externalize large context when needed +- leave artifact references, open questions, and recommended next step + +Expensive work must never disappear into `null`. + +### 8. Escalate intentionally + +Request user approval when the work must: + +- widen scope materially +- consume a larger budget profile +- add new external testing lanes +- spend protected reserve +- continue after a circuit breaker or hard stop + +## Required closure summary + +At the end of bounded or engagement work, summarize actual usage: + +- request class +- selected profile +- model/tool/delegation usage +- checkpoints created +- handoffs created +- reserve status +- remaining limitations +- next recommended step + +## Separation of concerns + +Keep these layers distinct: + +### KAG +Relevance and knowledge selection for the current question. + +### Headroom +Selective retrieval and compression for large artifacts. + +### Cost & Context Governance +Execution control, resource envelopes, context growth control, delegation control, progress breakers, checkpointing, and resumability. + +### Institutional Learning +Prior operational judgment and lessons learned that tighten decisions. + +### Security QA +Independent validation of claims, evidence quality, and closure readiness. + +None of the other layers replace governance, and governance does not replace them. + +## Minimum checklist for bounded/engagement work + +- [ ] Request classified +- [ ] Budget profile chosen +- [ ] Isolated state established +- [ ] QA reserve protected +- [ ] Child budgets bounded +- [ ] Child context packages bounded +- [ ] Tool allowlist minimized +- [ ] Progress signals defined +- [ ] Checkpoint triggers defined +- [ ] Partial handoff format available +- [ ] Closure usage summary produced diff --git a/tools/headroom_phase1/execution_output_optimizer.py b/tools/headroom_phase1/execution_output_optimizer.py new file mode 100644 index 0000000..ef3dce9 --- /dev/null +++ b/tools/headroom_phase1/execution_output_optimizer.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from typing import Mapping + +DEFAULT_ALLOWLIST = ( + "git_status", + "git_diff_stat", + "ls", + "find", + "tree", + "git_fetch", +) + +EXCLUDED_OPERATION_CLASSES = { + "read_file", + "search_files", + "grep", + "evidence_artifact", + "report", + "finding", + "sarif", + "sbom", + "poc", + "http_trace", + "sme_response", + "security_qa_response", +} + + +@dataclass(frozen=True) +class OptimizerConfig: + enabled: bool + mode: str + allowlist: tuple[str, ...] + + +@dataclass(frozen=True) +class OptimizationResult: + operation_class: str + enabled: bool + mode: str + optimizer_applied: bool + delivered_output: str + authoritative_raw_output: str + reduced_output: str | None + reason: str + + def to_dict(self) -> dict[str, object]: + return { + "operation_class": self.operation_class, + "enabled": self.enabled, + "mode": self.mode, + "optimizer_applied": self.optimizer_applied, + "delivered_output": self.delivered_output, + "authoritative_raw_output": self.authoritative_raw_output, + "reduced_output": self.reduced_output, + "reason": self.reason, + } + + +def _is_truthy(value: str | None, default: bool = True) -> bool: + if value is None: + return default + return value.strip().lower() not in {"0", "false", "no", "off", "disabled", ""} + + +def _normalize_mode(value: str | None) -> str: + mode = (value or "shadow").strip().lower() + return mode if mode in {"shadow", "passthrough"} else "shadow" + + +def _normalize_allowlist(raw: str | None) -> tuple[str, ...]: + if not raw or not raw.strip(): + return DEFAULT_ALLOWLIST + values = [] + for item in raw.split(","): + cleaned = item.strip() + if cleaned: + values.append(cleaned) + return tuple(values) or DEFAULT_ALLOWLIST + + +def load_optimizer_config(env: Mapping[str, str] | None = None) -> OptimizerConfig: + env_map = env or os.environ + return OptimizerConfig( + enabled=_is_truthy(env_map.get("MINICISO_EXECUTION_OUTPUT_OPTIMIZER"), default=True), + mode=_normalize_mode(env_map.get("MINICISO_EXECUTION_OUTPUT_OPTIMIZER_MODE")), + allowlist=_normalize_allowlist(env_map.get("MINICISO_EXECUTION_OUTPUT_OPTIMIZER_ALLOWLIST")), + ) + + +def _head_tail(lines: list[str], *, head: int = 20, tail: int = 5) -> str: + if len(lines) <= head + tail: + return "\n".join(lines) + omitted = len(lines) - (head + tail) + return "\n".join(lines[:head] + [f"... ({omitted} lines omitted) ..."] + lines[-tail:]) + + +def _summarize_git_status(raw_output: str) -> str: + lines = [line.rstrip() for line in raw_output.splitlines() if line.strip()] + modified = sum(1 for line in lines if line.startswith("modified:")) + deleted = sum(1 for line in lines if line.startswith("deleted:")) + untracked = sum(1 for line in lines if line.startswith("Untracked files:") or line.startswith("\t")) + summary = { + "kind": "git_status", + "line_count": len(lines), + "modified": modified, + "deleted": deleted, + "untracked_markers": untracked, + "preview": _head_tail(lines, head=12, tail=4), + } + return json.dumps(summary, ensure_ascii=False, indent=2) + + +def _summarize_git_diff_stat(raw_output: str) -> str: + lines = [line.rstrip() for line in raw_output.splitlines() if line.strip()] + changed_files = [line for line in lines if "|" in line] + total_line = next((line for line in reversed(lines) if re.search(r"\d+ files? changed", line)), "") + summary = { + "kind": "git_diff_stat", + "changed_files": len(changed_files), + "total": total_line, + "preview": _head_tail(lines, head=15, tail=2), + } + return json.dumps(summary, ensure_ascii=False, indent=2) + + +def _summarize_listing(operation_class: str, raw_output: str) -> str: + lines = [line.rstrip() for line in raw_output.splitlines() if line.strip()] + summary = { + "kind": operation_class, + "line_count": len(lines), + "preview": _head_tail(lines, head=25, tail=5), + } + return json.dumps(summary, ensure_ascii=False, indent=2) + + +def _summarize_git_fetch(raw_output: str) -> str: + lines = [line.rstrip() for line in raw_output.splitlines() if line.strip()] + summary = { + "kind": "git_fetch", + "line_count": len(lines), + "preview": _head_tail(lines, head=20, tail=4), + } + return json.dumps(summary, ensure_ascii=False, indent=2) + + +def _build_reduced_output(operation_class: str, raw_output: str) -> str: + if operation_class == "git_status": + return _summarize_git_status(raw_output) + if operation_class == "git_diff_stat": + return _summarize_git_diff_stat(raw_output) + if operation_class in {"ls", "find", "tree"}: + return _summarize_listing(operation_class, raw_output) + if operation_class == "git_fetch": + return _summarize_git_fetch(raw_output) + raise ValueError(f"Unsupported operation class: {operation_class}") + + +def optimize_output(operation_class: str, raw_output: str, env: Mapping[str, str] | None = None) -> OptimizationResult: + config = load_optimizer_config(env) + normalized_class = (operation_class or "").strip() + passthrough_reason = "passthrough" + + if not config.enabled: + return OptimizationResult(normalized_class, False, config.mode, False, raw_output, raw_output, None, "kill_switch_disabled") + if config.mode == "passthrough": + return OptimizationResult(normalized_class, True, config.mode, False, raw_output, raw_output, None, passthrough_reason) + if normalized_class in EXCLUDED_OPERATION_CLASSES: + return OptimizationResult(normalized_class, True, config.mode, False, raw_output, raw_output, None, "excluded_operation_class") + if normalized_class not in config.allowlist: + return OptimizationResult(normalized_class, True, config.mode, False, raw_output, raw_output, None, "not_allowlisted") + + reduced = _build_reduced_output(normalized_class, raw_output) + return OptimizationResult(normalized_class, True, config.mode, True, raw_output, raw_output, reduced, "shadow_derivative_only") diff --git a/tools/headroom_phase1/hr_manual_wrapper.py b/tools/headroom_phase1/hr_manual_wrapper.py index 0f5986a..1cbfdd3 100755 --- a/tools/headroom_phase1/hr_manual_wrapper.py +++ b/tools/headroom_phase1/hr_manual_wrapper.py @@ -11,6 +11,8 @@ from pathlib import Path from typing import Any +from execution_output_optimizer import optimize_output + def sha256_hex(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @@ -107,6 +109,11 @@ def parse_args() -> argparse.Namespace: p.add_argument("--selection-query", default="", help="Path to KAG query JSON for selection-first shadow mode") p.add_argument("--selection-pack", default="", help="Path to retrieval pack JSON for selection-first shadow mode") p.add_argument("--selection-mode", default="shadow", choices=["shadow", "primary"], help="Whether selection-first is running in shadow mode or primary mode") + p.add_argument( + "--execution-operation-class", + default="", + help="Optional narrow RTK execution-output operation class (git_status, git_diff_stat, ls, find, tree, git_fetch). Raw output remains authoritative.", + ) return p.parse_args() @@ -125,6 +132,9 @@ def main() -> int: text = raw_bytes.decode(args.encoding, errors="replace") raw_json = parse_json_maybe(text) raw_top_level_keys = top_level_keys(raw_json) + execution_optimizer_result = None + if args.execution_operation_class: + execution_optimizer_result = optimize_output(args.execution_operation_class, text) enabled = os.getenv("MINICISO_HEADROOM_ENABLED", "1") != "0" command_used = "MINICISO_HEADROOM_ENABLED=%s %s" % ( @@ -288,6 +298,9 @@ def main() -> int: "elapsed_ms": elapsed_ms, }, "selection_first": selection_metadata, + "execution_output_optimizer": ( + execution_optimizer_result.to_dict() if execution_optimizer_result else {"enabled": False, "reason": "not_requested"} + ), } run_json.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/tools/headroom_phase1/tests/test_execution_output_optimizer.py b/tools/headroom_phase1/tests/test_execution_output_optimizer.py new file mode 100644 index 0000000..4d77b24 --- /dev/null +++ b/tools/headroom_phase1/tests/test_execution_output_optimizer.py @@ -0,0 +1,73 @@ +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from execution_output_optimizer import DEFAULT_ALLOWLIST, optimize_output, load_optimizer_config + + +class ExecutionOutputOptimizerTests(unittest.TestCase): + def test_default_config_is_enabled_shadow_and_uses_mvp_allowlist(self): + config = load_optimizer_config({}) + self.assertTrue(config.enabled) + self.assertEqual(config.mode, "shadow") + self.assertEqual(config.allowlist, DEFAULT_ALLOWLIST) + + def test_shadow_mode_keeps_raw_output_authoritative(self): + raw = "On branch main\nmodified: file_a.py\nmodified: file_b.py\n" + result = optimize_output("git_status", raw, {}) + + self.assertTrue(result.optimizer_applied) + self.assertEqual(result.delivered_output, raw) + self.assertEqual(result.authoritative_raw_output, raw) + self.assertIsNotNone(result.reduced_output) + assert result.reduced_output is not None + reduced = json.loads(result.reduced_output) + self.assertEqual(reduced["kind"], "git_status") + self.assertEqual(reduced["modified"], 2) + + def test_kill_switch_restores_passthrough(self): + raw = "a\nb\nc\n" + result = optimize_output("ls", raw, {"MINICISO_EXECUTION_OUTPUT_OPTIMIZER": "0"}) + + self.assertFalse(result.optimizer_applied) + self.assertEqual(result.delivered_output, raw) + self.assertIsNone(result.reduced_output) + self.assertEqual(result.reason, "kill_switch_disabled") + + def test_excluded_classes_never_optimize(self): + raw = "critical evidence" + for operation_class in ("read_file", "sarif", "security_qa_response"): + with self.subTest(operation_class=operation_class): + result = optimize_output(operation_class, raw, {}) + self.assertFalse(result.optimizer_applied) + self.assertEqual(result.delivered_output, raw) + self.assertIsNone(result.reduced_output) + self.assertEqual(result.reason, "excluded_operation_class") + + def test_allowlist_can_narrow_supported_classes(self): + raw = "file1\nfile2\nfile3\n" + env = {"MINICISO_EXECUTION_OUTPUT_OPTIMIZER_ALLOWLIST": "git_status"} + + denied = optimize_output("ls", raw, env) + allowed = optimize_output("git_status", raw, env) + + self.assertFalse(denied.optimizer_applied) + self.assertEqual(denied.reason, "not_allowlisted") + self.assertTrue(allowed.optimizer_applied) + + def test_listing_preview_truncates_large_output(self): + raw = "\n".join(f"path-{i}" for i in range(40)) + result = optimize_output("find", raw, {}) + assert result.reduced_output is not None + reduced = json.loads(result.reduced_output) + + self.assertEqual(reduced["kind"], "find") + self.assertEqual(reduced["line_count"], 40) + self.assertIn("omitted", reduced["preview"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/headroom_phase1/tests/test_hr_manual_wrapper.py b/tools/headroom_phase1/tests/test_hr_manual_wrapper.py index a54295d..0ba5818 100644 --- a/tools/headroom_phase1/tests/test_hr_manual_wrapper.py +++ b/tools/headroom_phase1/tests/test_hr_manual_wrapper.py @@ -1,12 +1,14 @@ import json +import os import sys import tempfile import unittest from pathlib import Path +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from hr_manual_wrapper import build_selection_metadata +from hr_manual_wrapper import build_selection_metadata, main class BuildSelectionMetadataTests(unittest.TestCase): @@ -76,5 +78,49 @@ def test_build_selection_metadata_reads_query_index_and_pack_summary(self): ) +class ExecutionOutputOptimizerIntegrationTests(unittest.TestCase): + def test_wrapper_logs_shadow_derivative_but_preserves_authoritative_raw_output(self): + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + input_path = tmp / "ls.txt" + output_path = tmp / "out.txt" + log_dir = tmp / "logs" + raw_output = "\n".join(f"file_{i}" for i in range(40)) + "\n" + input_path.write_text(raw_output, encoding="utf-8") + + argv = [ + "hr_manual_wrapper.py", + str(input_path), + str(output_path), + "--log-dir", + str(log_dir), + "--artifact-type", + "command-output", + "--execution-operation-class", + "ls", + ] + env = os.environ.copy() + env["MINICISO_HEADROOM_ENABLED"] = "0" + env["MINICISO_EXECUTION_OUTPUT_OPTIMIZER"] = "1" + env["MINICISO_EXECUTION_OUTPUT_OPTIMIZER_MODE"] = "shadow" + + with patch.object(sys, "argv", argv), patch.dict(os.environ, env, clear=True): + rc = main() + + self.assertEqual(rc, 0) + self.assertEqual(output_path.read_text(encoding="utf-8"), raw_output) + + run_files = sorted(log_dir.glob("*.json")) + self.assertEqual(len(run_files), 1) + record = json.loads(run_files[0].read_text(encoding="utf-8")) + optimizer = record["execution_output_optimizer"] + self.assertTrue(optimizer["enabled"]) + self.assertTrue(optimizer["optimizer_applied"]) + self.assertEqual(optimizer["reason"], "shadow_derivative_only") + self.assertEqual(optimizer["authoritative_raw_output"], raw_output) + self.assertIn('"kind": "ls"', optimizer["reduced_output"]) + self.assertEqual(record["compressed"]["chars"], len(raw_output)) + + if __name__ == "__main__": unittest.main() From 5a966e0dcf7071b26fa2015fc7ff29272ef0835d Mon Sep 17 00:00:00 2001 From: Irlan Cidade <2146925357+icidade@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:40:22 +0000 Subject: [PATCH 4/4] fix(headroom): preserve raw output newlines on Windows --- tools/headroom_phase1/hr_manual_wrapper.py | 4 ++-- .../tests/test_hr_manual_wrapper.py | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tools/headroom_phase1/hr_manual_wrapper.py b/tools/headroom_phase1/hr_manual_wrapper.py index 1cbfdd3..d3f34db 100755 --- a/tools/headroom_phase1/hr_manual_wrapper.py +++ b/tools/headroom_phase1/hr_manual_wrapper.py @@ -171,7 +171,7 @@ def main() -> int: mode = "kill-switch-passthrough" output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(output_text, encoding=args.encoding) + output_path.write_bytes(output_text.encode(args.encoding)) output_bytes = output_path.read_bytes() output_json = parse_json_maybe(output_text) output_top_level_keys = top_level_keys(output_json) @@ -217,7 +217,7 @@ def main() -> int: quality_flags.extend(["sbom_component_count_reduced", "raw_required_for_sbom_authority"]) guard_actions.append("sbom_guard_reverted_to_raw") output_text = text - output_path.write_text(output_text, encoding=args.encoding) + output_path.write_bytes(output_text.encode(args.encoding)) output_bytes = output_path.read_bytes() output_json = raw_json output_top_level_keys = raw_top_level_keys diff --git a/tools/headroom_phase1/tests/test_hr_manual_wrapper.py b/tools/headroom_phase1/tests/test_hr_manual_wrapper.py index 0ba5818..0ebcdcc 100644 --- a/tools/headroom_phase1/tests/test_hr_manual_wrapper.py +++ b/tools/headroom_phase1/tests/test_hr_manual_wrapper.py @@ -85,8 +85,8 @@ def test_wrapper_logs_shadow_derivative_but_preserves_authoritative_raw_output(s input_path = tmp / "ls.txt" output_path = tmp / "out.txt" log_dir = tmp / "logs" - raw_output = "\n".join(f"file_{i}" for i in range(40)) + "\n" - input_path.write_text(raw_output, encoding="utf-8") + raw_output = "\r\n".join(f"file_{i}" for i in range(40)) + "\r\n" + input_path.write_bytes(raw_output.encode("utf-8")) argv = [ "hr_manual_wrapper.py", @@ -104,11 +104,22 @@ def test_wrapper_logs_shadow_derivative_but_preserves_authoritative_raw_output(s env["MINICISO_EXECUTION_OUTPUT_OPTIMIZER"] = "1" env["MINICISO_EXECUTION_OUTPUT_OPTIMIZER_MODE"] = "shadow" - with patch.object(sys, "argv", argv), patch.dict(os.environ, env, clear=True): + original_write_text = Path.write_text + + def windows_text_write_text(path, data, *args, **kwargs): + if path == output_path: + data = data.replace("\n", "\r\n") + return original_write_text(path, data, *args, **kwargs) + + with ( + patch.object(sys, "argv", argv), + patch.dict(os.environ, env, clear=True), + patch.object(Path, "write_text", new=windows_text_write_text), + ): rc = main() self.assertEqual(rc, 0) - self.assertEqual(output_path.read_text(encoding="utf-8"), raw_output) + self.assertEqual(output_path.read_bytes(), raw_output.encode("utf-8")) run_files = sorted(log_dir.glob("*.json")) self.assertEqual(len(run_files), 1)