diff --git a/.github/workflows/cmind-pre-release.yml b/.github/workflows/cmind-pre-release.yml index b1ccf42..beca2a8 100644 --- a/.github/workflows/cmind-pre-release.yml +++ b/.github/workflows/cmind-pre-release.yml @@ -7,11 +7,17 @@ on: paths: - "CoderMind/**" - ".github/workflows/cmind-pre-release.yml" + - ".github/workflows/cmind-security-tests.yml" - ".github/workflows/scripts/cmind/**" workflow_dispatch: jobs: + security: + permissions: + contents: read + uses: ./.github/workflows/cmind-security-tests.yml pre-release: + needs: security runs-on: ubuntu-latest permissions: contents: write @@ -40,8 +46,19 @@ jobs: - name: Create release package variants if: steps.check_release.outputs.exists == 'false' + env: + SCRIPTS: "sh ps" run: .github/workflows/scripts/cmind/create-release-packages.sh "${{ steps.get_tag.outputs.new_version }}" + - uses: actions/setup-python@v5 + if: steps.check_release.outputs.exists == 'false' + with: + python-version: "3.12" + + - name: Verify all 22 freshly built publishing ZIPs + if: steps.check_release.outputs.exists == 'false' + run: python3 -I -B CoderMind/tests/verify_release_security.py CoderMind/.genreleases + - name: Generate release notes if: steps.check_release.outputs.exists == 'false' run: .github/workflows/scripts/cmind/generate-release-notes.sh "${{ steps.get_tag.outputs.new_version }}" "${{ steps.get_tag.outputs.latest_tag }}" pre diff --git a/.github/workflows/cmind-release.yml b/.github/workflows/cmind-release.yml index 246c455..60fc861 100644 --- a/.github/workflows/cmind-release.yml +++ b/.github/workflows/cmind-release.yml @@ -6,11 +6,17 @@ on: paths: - "CoderMind/**" - ".github/workflows/cmind-release.yml" + - ".github/workflows/cmind-security-tests.yml" - ".github/workflows/scripts/cmind/**" workflow_dispatch: jobs: + security: + permissions: + contents: read + uses: ./.github/workflows/cmind-security-tests.yml release: + needs: security runs-on: ubuntu-latest permissions: contents: write @@ -43,8 +49,19 @@ jobs: - name: Create release package variants if: steps.check_release.outputs.exists == 'false' + env: + SCRIPTS: "sh ps" run: .github/workflows/scripts/cmind/create-release-packages.sh "${{ steps.get_tag.outputs.new_version }}" + - uses: actions/setup-python@v5 + if: steps.check_release.outputs.exists == 'false' + with: + python-version: "3.12" + + - name: Verify all 22 freshly built publishing ZIPs + if: steps.check_release.outputs.exists == 'false' + run: python3 -I -B CoderMind/tests/verify_release_security.py CoderMind/.genreleases + - name: Generate release notes if: steps.check_release.outputs.exists == 'false' run: .github/workflows/scripts/cmind/generate-release-notes.sh "${{ steps.get_tag.outputs.new_version }}" "${{ steps.get_tag.outputs.latest_tag }}" stable diff --git a/.github/workflows/cmind-security-tests.yml b/.github/workflows/cmind-security-tests.yml new file mode 100644 index 0000000..af88079 --- /dev/null +++ b/.github/workflows/cmind-security-tests.yml @@ -0,0 +1,59 @@ +name: CoderMind Security Regressions + +on: + pull_request: + paths: + - "CoderMind/**" + - ".github/workflows/cmind-*.yml" + - ".github/workflows/scripts/cmind/**" + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + security-tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build and install wheel on disposable CI runner + run: | + python -m pip install build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -m build --wheel CoderMind --outdir "$env:RUNNER_TEMP/wheels" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $wheel = @(Get-ChildItem "$env:RUNNER_TEMP/wheels" -Filter '*.whl') + if ($wheel.Count -ne 1) { throw 'Expected one wheel' } + python -m pip install $wheel[0].FullName + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Source policy and migration regressions (no real subprocesses) + run: python -I -B CoderMind/tests/run_security_tests.py + - name: Installed wheel regressions and source identity + run: python -I -B CoderMind/tests/run_security_tests.py --installed + - name: Inert execution regressions against installed wheel + run: python -I -B CoderMind/tests/run_security_tests.py --installed --integration + - name: Build sh and ps release ZIPs with Bash packager + if: runner.os == 'Linux' + shell: bash + env: + SCRIPTS: "sh ps" + run: bash .github/workflows/scripts/cmind/create-release-packages.sh v0.0.0-security-test + - name: Build sh and ps release ZIPs with PowerShell packager + if: runner.os == 'Windows' + run: ./.github/workflows/scripts/cmind/create-release-packages.ps1 -Version v0.0.0-security-test -Scripts 'sh ps' + - name: Verify all 22 release variants carry the reviewed Python scripts + run: python -I -B CoderMind/tests/verify_release_security.py CoderMind/.genreleases diff --git a/.github/workflows/scripts/cmind/create-release-packages.ps1 b/.github/workflows/scripts/cmind/create-release-packages.ps1 index f2f45b9..1a3e89c 100755 --- a/.github/workflows/scripts/cmind/create-release-packages.ps1 +++ b/.github/workflows/scripts/cmind/create-release-packages.ps1 @@ -352,14 +352,15 @@ $AllAgents = @('copilot', 'claude', 'gemini', 'cursor-agent', 'qwen', 'opencode' $AllScripts = @('sh', 'ps') function Normalize-List { - param([string]$Input) + param([string]$Value) - if ([string]::IsNullOrEmpty($Input)) { + if ([string]::IsNullOrEmpty($Value)) { return @() } # Split by comma or space and remove duplicates while preserving order - $items = $Input -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique + # Do not use $Input: it is PowerShell's automatic pipeline enumerator. + $items = $Value -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique return $items } @@ -382,7 +383,7 @@ function Validate-Subset { # Determine agent list if (-not [string]::IsNullOrEmpty($Agents)) { - $AgentList = Normalize-List -Input $Agents + $AgentList = Normalize-List -Value $Agents if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) { exit 1 } @@ -392,7 +393,7 @@ if (-not [string]::IsNullOrEmpty($Agents)) { # Determine script list if (-not [string]::IsNullOrEmpty($Scripts)) { - $ScriptList = Normalize-List -Input $Scripts + $ScriptList = Normalize-List -Value $Scripts if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) { exit 1 } diff --git a/CoderMind/README.hi-IN.md b/CoderMind/README.hi-IN.md index 0498675..06db795 100644 --- a/CoderMind/README.hi-IN.md +++ b/CoderMind/README.hi-IN.md @@ -63,14 +63,14 @@ Reverse Direction: Code → RPG │ │ │ ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Existing Codebase│────────▶│ encode │──────▶│update_rpg│────────────┘ │ -│ │ │ (full) │ │ (manual │ │ -└──────────────────┘ └────┬─────┘ │ fallback)│ │ +│ │ │ (full) │ │ (explicit│ │ +└──────────────────┘ └────┬─────┘ │ update) │ │ rpg.json └──────────┘ │ dep_graph.json rpg.json / dep_graph.json │ │ │ └──────────────────────────────────────────┘ ▲ - │ post-commit hook normally runs incremental updates + │ opt-in post-commit hook: deterministic sync only (no AI update) MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ``` @@ -100,10 +100,12 @@ uv tool install cmind-cli --from "git+https://github.com/microsoft/RPG-ZeroRepo. cmind check # एक बार के उपयोग के लिए -uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init +uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init --ai claude ``` -`0.1.3` से, wheel pipeline scripts और slash-command templates को packaged assets के रूप में शामिल करता है, इसलिए `cmind init` ऑफ़लाइन वातावरणों (जैसे air-gapped या corporate proxy वातावरण) में भी काम करता है। +`0.1.3` से, wheel pipeline scripts और slash-command templates को packaged assets के रूप में शामिल करता है, इसलिए `cmind init` का टेम्पलेट सेटअप चरण ऑफ़लाइन काम करता है। वैकल्पिक प्रारंभिक एन्कोडिंग (`--encode`) AI सेवाओं को कॉल कर सकती है और इसके लिए नेटवर्क एक्सेस की आवश्यकता हो सकती है; `cmind update` के दौरान स्वयं को अपग्रेड करने के लिए भी नेटवर्क एक्सेस की आवश्यकता हो सकती है। + +उदाहरणों में `--ai claude` है; GitHub Copilot के लिए इसे `--ai copilot` से बदलें। इंटरैक्टिव init में `--ai` छोड़ने पर चयन पूछा जा सकता है। गैर-TTY init में स्पष्ट `--ai` अनिवार्य है, भले ही वातावरण चर से प्रदाता चुना गया हो। ## Quick Start: नई रिपॉज़िटरी @@ -115,7 +117,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder 1. नई परियोजना को आरंभीकृत करें: ```bash - cmind init my-project + cmind init my-project --ai claude cd my-project ``` @@ -146,26 +148,26 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder > - **Claude Code**: चैट में सीधे `/cmind.feature_construct ...` टाइप करें — slash command पहचाने जाते हैं और संबंधित workflow ट्रिगर हो जाता है। > - **GitHub Copilot CLI**: slash command समर्थित नहीं हैं (कस्टम agent समर्थित हैं), इसलिए पहले `/agent cmind.feature_construct` से लक्ष्य agent पर स्विच करें, फिर `start` टाइप करके इसका अंतर्निहित workflow चलाएँ। -CoderMind क्रमिक रूप से `~/.cmind/workspaces//data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। आपके वर्कस्पेस की स्रोत फ़़ाइलें दूषित नहीं होंगी। +CoderMind क्रमिक रूप से `~/.cmind/workspaces//data/rpg.json` बनाता है और इसका उपयोग आवश्यकताओं, प्लानिंग आउटपुट, जनरेटेड कोड और dependency जानकारी को संरेखित रखने के लिए करता है। मुख्य RPG डेटा रिपॉज़िटरी के बाहर रहता है; जनरेटेड रिपोर्ट वर्कस्पेस में रहती हैं। ## Quick Start: मौजूदा रिपॉज़िटरी जब आपके पास पहले से एक रिपॉज़िटरी है और आप चाहते हैं कि AI एजेंट इसे RPG कॉन्टेक्स्ट के साथ समझे या संपादित करे, तब इस मार्ग का उपयोग करें। > [!WARNING] -> बड़ी परियोजनाओं के लिए, `cmind init . --encode` और `/cmind.encode` को चलने में काफ़ी समय लग सकता है। उदाहरण: 200 स्रोत फ़ाइलों में लगभग 100 मिनट लगते हैं। +> बड़ी परियोजनाओं के लिए, `cmind init . --ai claude --encode` और `/cmind.encode` को चलने में काफ़ी समय लग सकता है। उदाहरण: 200 स्रोत फ़ाइलों में लगभग 100 मिनट लगते हैं। 1. रिपॉज़िटरी रूट में CoderMind को आरंभीकृत करें और प्रारंभिक ग्राफ़ बनाएँ: ```bash cd existing-repo/ - cmind init . --encode # --encode वर्तमान कोड से RPG उत्पन्न करता है + cmind init . --ai claude --encode # --encode वर्तमान कोड से RPG उत्पन्न करता है ``` यदि आप गैर-खाली निर्देशिका के लिए पुष्टि संकेत को छोड़ना चाहते हैं: ```bash - cmind init . --force --encode + cmind init . --ai claude --force --encode ``` 2. रिपॉज़िटरी में अपना AI कोडिंग एजेंट लॉन्च करें। @@ -174,40 +176,60 @@ CoderMind क्रमिक रूप से `~/.cmind/workspaces/ # ग्राफ़-जागरूक कोड संपादन ``` -4. हर commit के बाद, CoderMind द्वारा इंस्टॉल किया गया git hook स्वचालित रूप से `cmind hook ` dispatcher को कॉल करता है, RPG को अपडेट करता है और उसे कोड परिवर्तनों के साथ संरेखित रखता है। यदि hook विफल हो जाता है या छोड़ दिया जाता है, तो `/cmind.update_rpg` मैन्युअल रूप से चलाएँ। +4. AI आधारित ग्राफ़ अपडेट के लिए `/cmind.update_rpg` स्पष्ट रूप से चलाएँ। Git hooks डिफ़ॉल्ट रूप से बंद हैं; सक्षम करने पर भी वे केवल फ़ोरग्राउंड में नियतात्मक सिंक करते हैं, AI अपडेट या बैकग्राउंड कार्य नहीं चलाते। ## `cmind init` के बाद क्या होता है -`cmind init` आपकी स्रोत फ़़ाइलों को संशोधित नहीं करता है, **और आपके वर्कस्पेस में रनटाइम स्टेट नहीं लिखता है**। यह आपके वर्कस्पेस में केवल command definitions, MCP कॉन्फ़़िगरेशन और hooks जोड़ता है। CoderMind का रनटाइम डेटा (outputs और logs) home-side निर्देशिका `~/.cmind/workspaces//` के अंतर्गत रखा जाता है, जहाँ `` वर्कस्पेस के absolute path से जनित एक पठनीय slug है (उदाहरण: `home-hys-projects-myrepo`)। +`cmind init` स्रोत फ़ाइलों को बदले बिना कमांड परिभाषाएँ, वर्कस्पेस मार्कर/कॉन्फ़िगरेशन, MCP पंजीकरण और स्टेटस एकीकरण सेट करता है। जनरेटेड रिपोर्ट वर्कस्पेस में रहती हैं; मुख्य रनटाइम डेटा (RPG आउटपुट और लॉग) `~/.cmind/workspaces//` में रहता है। `` वर्कस्पेस के absolute path से बना पठनीय slug है (उदाहरण: `home-hys-projects-myrepo`)। ```text my-project/ ├── docs/ # /cmind.feature_construct के लिए वैकल्पिक आवश्यकता दस्तावेज़ ├── .github/ or .claude/ # Coding Agent कमांड परिभाषाएँ और सेटिंग्स -├── .vscode/ # लागू होने पर Copilot/VS Code MCP कॉन्फ़िगरेशन -├── .cmind/ # जनरेटेड रिपोर्ट और कॉन्फ़िगरेशन फ़ाइलें -└── .git/hooks/ # cmind init द्वारा इंस्टॉल किए गए post-commit / post-merge (प्रत्येक hook केवल एक पंक्ति: `cmind hook `) +├── .vscode/ # लागू होने पर Copilot/VS Code MCP और स्टेटस एकीकरण +├── .cmind/ # वर्कस्पेस मार्कर/कॉन्फ़िगरेशन और जनरेटेड रिपोर्ट +└── .git/hooks/ # वैकल्पिक post-commit / post-merge, केवल नियतात्मक सिंक ``` +CoderMind के Git hooks **डिफ़ॉल्ट रूप से बंद** हैं। नियतात्मक सिंक के लिए **हर** init/update में `--git-hooks` देना आवश्यक है। इसे छोड़ने या `--no-git-hooks` देने पर केवल पहचाने गए CoderMind-प्रबंधित ब्लॉक हटते हैं; अन्य उपयोगकर्ता/टीम hooks की सामग्री सुरक्षित रहती है। CoderMind-प्रबंधित Git hooks AI या बैकग्राउंड कार्य नहीं चलाते। Claude का `SessionStart` और Copilot/VS Code का `folderOpen` पहले की तरह **केवल स्टेटस दिखाते हैं**; वे Git hooks से अलग हैं और `--no-git-hooks` से प्रभावित नहीं होते। + पूर्ण लेआउट और डेटा फ़ाइल संदर्भ के लिए [docs/project-structure.md](docs/project-structure.md) देखें। +### निष्पादन कॉन्फ़िगरेशन + +रिपॉज़िटरी के `recommended_provider` और मान्य पुराने `ai_provider` / बिल्ट-इन मान से हूबहू मेल खाते `ai_cli_cmd` केवल सुझाव हैं, **रनटाइम निष्पादन की अनुमति नहीं**। Init आपका स्पष्ट चयन रिपॉज़िटरी के बाहर `~/.cmind/execution//selection.json` में सहेजता है। यह वर्कस्पेस के कैनॉनिकल पथ से जुड़ा है और slug-आधारित RPG डेटा से अलग है। [लोकल निष्पादन चयन](docs/configuration.md#user-local-execution-selection) देखें। + +किसी अन्य पथ पर क्लोन करने, वर्कस्पेस को स्थानांतरित करने या नया उपयोगकर्ता होने पर दोबारा चयन करना होगा। विश्वसनीय CI प्रक्रिया के निष्पादन के लिए `CMIND_AI_PROVIDER` उपयोग कर सकता है; गैर-TTY init में फिर भी `--ai` चाहिए। `cmind update --ai claude` सहेजे गए चयन को स्पष्ट रूप से बदलता है। `--ai` के बिना update चयन बनाए रखता है (या अनुपस्थित ही छोड़ता है); रिपॉज़िटरी के सुझावों या पहचाने गए एकीकरणों से अपने आप अनुमति नहीं देता। + +असुरक्षित raw-command या अमान्य कॉन्फ़िगरेशन होने पर init/update वर्कस्पेस सेटअप या hook माइग्रेशन से पहले की जाँच में विफल होते हैं। `--ai`, `--force` और वातावरण चर से किया गया ओवरराइड इस जाँच को बायपास नहीं करते। [माइग्रेशन चेकलिस्ट](docs/configuration.md#updating-an-existing-codermind-project) के अनुसार कॉन्फ़िगरेशन सुधारें। + ## CoderMind अपडेट करें +पहले अपने इंस्टॉलेशन तरीके से सुधारों वाला CLI इंस्टॉल करें, उदाहरण के लिए: + ```bash uv tool install cmind-cli \ --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" \ --force \ --reinstall +``` + +फिर **हर मौजूदा वर्कस्पेस** का माइग्रेशन करें: अमान्य कॉन्फ़िगरेशन जाँचकर सुधारें, फिर नीचे दिए गए कमांड से स्पष्ट चयन करें (Copilot के लिए `claude` को `copilot` से बदलें)। केवल CLI इंस्टॉल करने से वर्कस्पेस के पुराने hooks साफ़ नहीं होते। -# किसी मौजूदा वर्कस्पेस को अपडेट करें +```bash cd +cmind update --ai claude --no-upgrade --no-git-hooks + +# नियमित अपडेट लोकल चयन बनाए रखते हैं; अनुपस्थित निष्पादन सहमति नहीं बनाते cmind update ``` +न पहचाने गए पुराने hooks की मैन्युअल समीक्षा करें और असंबंधित उपयोगकर्ता/टीम hooks सुरक्षित रखें। केवल नियतात्मक सिंक चाहिए तो हर update में `--git-hooks` दें। [माइग्रेशन चेकलिस्ट](docs/configuration.md#updating-an-existing-codermind-project) देखें। + ## समर्थित प्लेटफ़ॉर्म्स **Coding Agent समर्थन**: @@ -226,11 +248,13 @@ cmind update | macOS | ⌛ | | Windows | ⌛ | +Windows समर्थन अभी आंशिक है: टेम्पलेट केवल `sh` के लिए हैं (`ps` समर्थित नहीं है)। AI लॉन्च में इंस्टॉल की गई नेटिव `.exe` या समर्थित, ज्ञात Claude/Copilot npm एंट्री ही उपयोग होती हैं; `.cmd`, `.bat` और `.ps1` रैपर कभी नहीं चलाए जाते। यह वास्तविक रिलीज़ की संगतता की गारंटी नहीं है। प्रदाता की सामान्य अनुमोदन प्रक्रिया लागू रहती है; व्यापक अनुमति-बायपास नहीं है। केवल पढ़ने वाले MCP टूल्स का पूर्व-अनुमोदन अलग सेटिंग है। [निष्पादन फ़ाइल नीति](docs/configuration.md#executable-and-permission-policy) और [MCP अनुमतियाँ](docs/configuration.md#assistant-permissions-and-scope) देखें। + ## दस्तावेज़ीकरण - [स्लैश कमांड संदर्भ](docs/commands.md) — हर `/cmind.*` कमांड के लिए इनपुट, आउटपुट और उदाहरण। - [CLI संदर्भ](docs/cli-reference.md) — `cmind init`, `cmind update`, `cmind check`, `cmind version` और सभी विकल्प। -- [कॉन्फ़िगरेशन](docs/configuration.md) — AI सहायक सेटअप, MCP पंजीकरण, hooks, ऑटो-अनुमोदन और समस्या-निवारण। +- [कॉन्फ़िगरेशन](docs/configuration.md) — लोकल प्रदाता चयन, MCP अनुमतियाँ, वैकल्पिक hooks, माइग्रेशन और समस्या-निवारण। - [परियोजना संरचना](docs/project-structure.md) — CoderMind द्वारा बनाई गई फ़ाइलें और निर्देशिकाएँ। ## आगामी सुविधाएँ diff --git a/CoderMind/README.ja-JP.md b/CoderMind/README.ja-JP.md index 208622d..9f99551 100644 --- a/CoderMind/README.ja-JP.md +++ b/CoderMind/README.ja-JP.md @@ -63,14 +63,14 @@ Reverse Direction: Code → RPG │ │ │ ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Existing Codebase│────────▶│ encode │──────▶│update_rpg│────────────┘ │ -│ │ │ (full) │ │ (manual │ │ -└──────────────────┘ └────┬─────┘ │ fallback)│ │ +│ │ │ (full) │ │ (explicit│ │ +└──────────────────┘ └────┬─────┘ │ update) │ │ rpg.json └──────────┘ │ dep_graph.json rpg.json / dep_graph.json │ │ │ └──────────────────────────────────────────┘ ▲ - │ post-commit hook normally runs incremental updates + │ opt-in post-commit hook: deterministic sync only (no AI update) MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ``` @@ -100,10 +100,12 @@ uv tool install cmind-cli --from "git+https://github.com/microsoft/RPG-ZeroRepo. cmind check # 一度きりの使用 -uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init +uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init --ai claude ``` -`0.1.3` 以降、wheel には pipeline scripts と slash-command templates が packaged assets として同梱されるため、`cmind init` はオフライン環境(air-gapped 環境や企業プロキシ環境など)でも動作します。 +`0.1.3` 以降、wheel には pipeline scripts と slash-command templates が packaged assets として同梱されるため、`cmind init` のテンプレート配置ステップはオフラインで実行できます。任意の初期エンコード(`--encode`)は AI サービスを呼び出し、ネットワークアクセスを必要とする場合があります。`cmind update` による自己アップグレードもネットワークアクセスを必要とする場合があります。 + +例では `--ai claude` を使います。GitHub Copilot の場合は `--ai copilot` に置き換えてください。対話型の初期化では `--ai` を省略すると選択を求められますが、非 TTY の初期化では環境変数による指定があっても明示的な `--ai` が必要です。 ## クイックスタート: 新規リポジトリ @@ -115,7 +117,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder 1. 新しいプロジェクトを初期化します: ```bash - cmind init my-project + cmind init my-project --ai claude cd my-project ``` @@ -146,26 +148,26 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder > - **Claude Code**:チャットにそのまま `/cmind.feature_construct ...` と入力します。slash command が認識され、対応する workflow がトリガーされます。 > - **GitHub Copilot CLI**:slash command はサポートされません(カスタム agent はサポート)。まず `/agent cmind.feature_construct` で目的の agent に切り替え、その後 `start` と入力して内蔵の workflow を実行します。 -CoderMind は `~/.cmind/workspaces//data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。ワークスペースのソースファイルは汚染されません。 +CoderMind は `~/.cmind/workspaces//data/rpg.json` を段階的に作成し、それを使って要件・計画成果物・生成コード・依存情報を整合した状態に保ちます。主要な RPG データはリポジトリ外に保存され、生成されたレポートはワークスペース内に残ります。 ## クイックスタート: 既存リポジトリ すでにリポジトリがあり、AI エージェントに RPG コンテキストで理解または編集させたい場合は、こちらの手順を使います。 > [!WARNING] -> 大きめのプロジェクトでは、`cmind init . --encode` と `/cmind.encode` の実行に時間がかかることがあります。例として、200 ファイルでおおよそ 100 分かかります。 +> 大きめのプロジェクトでは、`cmind init . --ai claude --encode` と `/cmind.encode` の実行に時間がかかることがあります。例として、200 ファイルでおおよそ 100 分かかります。 1. リポジトリのルートで CoderMind を初期化し、初期グラフを構築します: ```bash cd existing-repo/ - cmind init . --encode # --encode は現在のコードから RPG を生成します + cmind init . --ai claude --encode # --encode は現在のコードから RPG を生成します ``` 空でないディレクトリでの確認プロンプトをスキップしたい場合: ```bash - cmind init . --force --encode + cmind init . --ai claude --force --encode ``` 2. リポジトリで AI コーディングエージェントを起動します。 @@ -174,40 +176,60 @@ CoderMind は `~/.cmind/workspaces//data/rpg.json` を段階的に ```text /cmind.encode # 必要に応じて完全な RPG を再構築 - /cmind.update_rpg # 手動の増分更新(フォールバック) + /cmind.update_rpg # AI による増分更新を明示的に要求 /cmind.rpg_edit # グラフ認識型のコード編集 ``` -4. 各 commit の後、CoderMind がインストールした git hook が `cmind hook ` ディスパッチャを自動的に呼び出し、RPG を更新してコード変更と整合した状態に保ちます。hook が失敗したりスキップされたりした場合は、`/cmind.update_rpg` を手動で実行してください。 +4. AI によるグラフ更新は `/cmind.update_rpg` で明示的に要求してください。Git hooks はデフォルトで無効です。有効にしてもフォアグラウンドで決定論的な同期を行うだけで、AI 更新やバックグラウンド処理は起動しません。 ## `cmind init` の後に起きること -`cmind init` はソースファイルを変更しません。また、**ワークスペースにランタイム状態を書き込みません**。ワークスペースには command 定義、MCP 設定、および hooks のみを追加します。CoderMind のランタイムデータ(成果物、ログ)は home-side ディレクトリ `~/.cmind/workspaces//` 下に配置されます。`` はワークスペースの絶対パスから導出される可読な slug です(例: `home-hys-projects-myrepo`)。 +`cmind init` はソースファイルを編集せず、コマンド定義、ワークスペースのマーカー/設定、MCP 登録、ステータス連携を設定します。生成されたレポートはワークスペース内に保存され、主要なランタイムデータ(RPG 成果物とログ)は `~/.cmind/workspaces//` 下に配置されます。`` はワークスペースの絶対パスから導出される可読な slug です(例: `home-hys-projects-myrepo`)。 ```text my-project/ ├── docs/ # /cmind.feature_construct 用の任意の要件ドキュメント ├── .github/ or .claude/ # Coding Agent のコマンド定義と設定 -├── .vscode/ # 該当する場合の Copilot/VS Code MCP 設定 -├── .cmind/ # 生成されたレポートと設定ファイル -└── .git/hooks/ # cmind init が設置する post-commit / post-merge(各 hook は 1 行のみ: `cmind hook `) +├── .vscode/ # 該当する場合の Copilot/VS Code MCP とステータス連携 +├── .cmind/ # ワークスペースのマーカー/設定と生成されたレポート +└── .git/hooks/ # 任意の post-commit / post-merge、決定論的な同期のみ ``` +CoderMind の Git hooks は**デフォルトで無効**です。決定論的な同期を有効にするには、init/update の**実行ごとに** `--git-hooks` を指定します。省略するか `--no-git-hooks` を指定すると、認識できる CoderMind 管理ブロックだけを削除し、その他のユーザー/チームの hook 内容は保持します。CoderMind が管理する Git hooks は AI やバックグラウンド処理を起動しません。Claude の `SessionStart` と Copilot/VS Code の `folderOpen` は引き続き**ステータス表示のみ**で、Git hooks とは独立しており、`--no-git-hooks` の影響を受けません。 + 完全なレイアウトとデータファイルのリファレンスは [docs/project-structure.md](docs/project-structure.md) を参照してください。 +### 実行設定 + +リポジトリの `recommended_provider` と、有効な旧形式の `ai_provider` / 組み込み値に完全一致する `ai_cli_cmd` は推奨情報にすぎず、**実行を許可するものではありません**。Init はユーザーの明示的な選択をリポジトリ外の `~/.cmind/execution//selection.json` に保存します。正規化されたワークスペースパスに紐づき、slug ベースの RPG データとは別です。[ローカル実行選択](docs/configuration.md#user-local-execution-selection)を参照してください。 + +別のパスへのクローン、ワークスペースの移動、別ユーザーでの利用では再選択が必要です。信頼できる CI はプロセスの実行に `CMIND_AI_PROVIDER` を使えますが、非 TTY の初期化には引き続き `--ai` が必要です。`cmind update --ai claude` は保存済みの選択を明示的に変更します。`--ai` なしの update は選択を保持し(未設定なら未設定のまま)、リポジトリの推奨情報や検出した連携から実行を自動許可しません。 + +安全でない生のコマンド指定や無効な設定があると、init/update はワークスペースの設定や hook 移行前の事前検証で失敗します。`--ai`、`--force`、環境変数による上書きでも検証は回避できません。[移行チェックリスト](docs/configuration.md#updating-an-existing-codermind-project)に従って設定を修正してください。 + ## CoderMind の更新 +まず、利用しているインストール方法で修正を含む CLI をインストールしてください。例: + ```bash uv tool install cmind-cli \ --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" \ --force \ --reinstall +``` + +次に、**既存の各ワークスペース**を移行します。無効な設定を確認・修正してから、以下で明示的に選択してください(Copilot を使う場合は `claude` を `copilot` に置き換えます)。CLI のインストールだけでは、ワークスペースの古い hooks は削除されません。 -# 既存のワークスペースを更新 +```bash cd +cmind update --ai claude --no-upgrade --no-git-hooks + +# 通常の更新はローカル選択を保持し、未設定の実行許可は作成しない cmind update ``` +認識されない旧 hooks は手動で確認し、無関係なユーザー/チームの hooks は保持してください。決定論的な同期が必要な場合のみ、update ごとに `--git-hooks` を指定します。[移行チェックリスト](docs/configuration.md#updating-an-existing-codermind-project)を参照してください。 + ## 対応プラットフォーム **Coding Agent サポート**: @@ -226,11 +248,13 @@ cmind update | macOS | ⌛ | | Windows | ⌛ | +Windows 対応は引き続き限定的です。テンプレートは `sh` のみで、`ps` は未対応です。AI の起動にはインストール済みのネイティブ `.exe` または対応する既知の Claude/Copilot npm エントリのみを使い、`.cmd`、`.bat`、`.ps1` ラッパーは実行しません。実際のリリースとの互換性を保証するものではありません。通常のプロバイダー承認が必要で、包括的な権限回避はありません。読み取り専用 MCP の事前承認は別の設定です。[実行ファイルポリシー](docs/configuration.md#executable-and-permission-policy)と [MCP 権限](docs/configuration.md#assistant-permissions-and-scope)を参照してください。 + ## ドキュメント - [スラッシュコマンドリファレンス](docs/commands.md) — すべての `/cmind.*` コマンドの入力・出力・例。 - [CLI リファレンス](docs/cli-reference.md) — `cmind init`、`cmind update`、`cmind check`、`cmind version` とすべてのオプション。 -- [設定](docs/configuration.md) — AI アシスタントのセットアップ、MCP 登録、フック、自動承認、およびトラブルシューティング。 +- [設定](docs/configuration.md) — ローカルのプロバイダー選択、MCP 権限、任意の hooks、移行、トラブルシューティング。 - [プロジェクト構造](docs/project-structure.md) — CoderMind が作成するファイルとディレクトリ。 ## 今後の機能 diff --git a/CoderMind/README.ko-KR.md b/CoderMind/README.ko-KR.md index da3212a..47d3d89 100644 --- a/CoderMind/README.ko-KR.md +++ b/CoderMind/README.ko-KR.md @@ -63,14 +63,14 @@ Reverse Direction: Code → RPG │ │ │ ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Existing Codebase│────────▶│ encode │──────▶│update_rpg│────────────┘ │ -│ │ │ (full) │ │ (manual │ │ -└──────────────────┘ └────┬─────┘ │ fallback)│ │ +│ │ │ (full) │ │ (explicit│ │ +└──────────────────┘ └────┬─────┘ │ update) │ │ rpg.json └──────────┘ │ dep_graph.json rpg.json / dep_graph.json │ │ │ └──────────────────────────────────────────┘ ▲ - │ post-commit hook normally runs incremental updates + │ opt-in post-commit hook: deterministic sync only (no AI update) MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ``` @@ -100,10 +100,12 @@ uv tool install cmind-cli --from "git+https://github.com/microsoft/RPG-ZeroRepo. cmind check # 일회성 사용 -uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init +uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init --ai claude ``` -`0.1.3` 부터 wheel은 pipeline scripts와 slash-command templates를 packaged assets로 함께 제공하므로, `cmind init` 은 오프라인 환경(air-gapped 환경, 회사 프록시 환경 등)에서도 동작합니다. +`0.1.3` 부터 wheel은 pipeline scripts와 slash-command templates를 packaged assets로 함께 제공하므로, `cmind init` 의 템플릿 배치 단계는 오프라인으로 수행할 수 있습니다. 선택적 초기 인코딩(`--encode`)은 AI 서비스를 호출하고 네트워크 접근이 필요할 수 있으며, `cmind update` 중 자체 업그레이드에도 네트워크 접근이 필요할 수 있습니다. + +예제는 `--ai claude` 를 사용합니다. GitHub Copilot을 사용하려면 `--ai copilot` 으로 바꾸세요. 대화형 초기화에서는 `--ai` 를 생략하면 선택을 묻지만, 비 TTY 초기화에서는 환경 변수로 지정해도 명시적인 `--ai` 가 필요합니다. ## Quick Start: 새 저장소 @@ -115,7 +117,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder 1. 새 프로젝트를 초기화합니다: ```bash - cmind init my-project + cmind init my-project --ai claude cd my-project ``` @@ -146,26 +148,26 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder > - **Claude Code**: 채팅에 직접 `/cmind.feature_construct ...` 을 입력하면 slash command가 인식되어 해당 workflow가 트리거됩니다. > - **GitHub Copilot CLI**: slash command는 지원하지 않으나(커스텀 agent는 지원), 먼저 `/agent cmind.feature_construct` 으로 대상 agent로 전환한 다음 `start` 를 입력해 내장된 workflow를 실행합니다. -CoderMind은 `~/.cmind/workspaces//data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. 워크스페이스의 소스 파일은 오염되지 않습니다. +CoderMind은 `~/.cmind/workspaces//data/rpg.json` 을 점진적으로 생성하고, 이를 사용해 요구사항, 계획 산출물, 생성된 코드, 의존성 정보를 정합 상태로 유지합니다. 주요 RPG 데이터는 저장소 밖에 저장하며, 생성된 보고서는 워크스페이스 안에 유지합니다. ## Quick Start: 기존 저장소 이미 저장소가 있고, AI 에이전트가 RPG 컨텍스트로 이해하거나 편집하기를 원할 때 이 경로를 사용하세요. > [!WARNING] -> 큰 프로젝트의 경우, `cmind init . --encode` 와 `/cmind.encode` 의 실행 시간이 길어질 수 있습니다. 예시: 200개 소스 파일은 약 100분이 걸립니다. +> 큰 프로젝트의 경우, `cmind init . --ai claude --encode` 와 `/cmind.encode` 의 실행 시간이 길어질 수 있습니다. 예시: 200개 소스 파일은 약 100분이 걸립니다. 1. 저장소 루트에서 CoderMind을 초기화하고 초기 그래프를 생성합니다: ```bash cd existing-repo/ - cmind init . --encode # --encode 는 현재 코드로부터 RPG를 생성합니다 + cmind init . --ai claude --encode # --encode 는 현재 코드로부터 RPG를 생성합니다 ``` 비어 있지 않은 디렉터리에 대한 확인 프롬프트를 건너뛰려면: ```bash - cmind init . --force --encode + cmind init . --ai claude --force --encode ``` 2. 저장소에서 AI 코딩 에이전트를 실행합니다. @@ -174,40 +176,60 @@ CoderMind은 `~/.cmind/workspaces//data/rpg.json` 을 점진적으 ```text /cmind.encode # 필요할 때 전체 RPG 재구축 - /cmind.update_rpg # 수동 증분 업데이트 (폴백) + /cmind.update_rpg # AI 기반 증분 업데이트를 명시적으로 요청 /cmind.rpg_edit # 그래프 인식 코드 편집 ``` -4. 각 commit 후, CoderMind이 설치한 git hook이 `cmind hook ` 디스패처를 자동으로 호출해 RPG를 업데이트하고 코드 변경과 정합된 상태로 유지합니다. hook이 실패하거나 건너뛰어진 경우 `/cmind.update_rpg` 를 수동으로 실행하세요. +4. AI 기반 그래프 업데이트는 `/cmind.update_rpg` 로 명시적으로 요청하세요. Git hooks는 기본적으로 꺼져 있으며, 활성화해도 포그라운드에서 결정적 동기화만 수행합니다. AI 업데이트나 백그라운드 작업은 실행하지 않습니다. ## `cmind init` 이후 일어나는 일 -`cmind init` 은 소스 파일을 수정하지 않습니다. 또한 **워크스페이스에 런타임 상태를 기록하지도 않습니다**. 워크스페이스에는 command 정의, MCP 구성, hooks만 추가합니다. CoderMind의 런타임 데이터(산출물, 로그)는 home-side 디렉터리 `~/.cmind/workspaces//` 아래에 배치되며, `` 는 워크스페이스의 절대 경로에서 파생된 가독성 있는 slug입니다 (예: `home-hys-projects-myrepo`). +`cmind init` 은 소스 파일을 수정하지 않고 커맨드 정의, 워크스페이스 마커/설정, MCP 등록, 상태 통합을 구성합니다. 생성된 보고서는 워크스페이스 안에 저장하며, 주요 런타임 데이터(RPG 산출물과 로그)는 `~/.cmind/workspaces//` 아래에 배치합니다. `` 는 워크스페이스의 절대 경로에서 파생된 가독성 있는 slug입니다 (예: `home-hys-projects-myrepo`). ```text my-project/ ├── docs/ # /cmind.feature_construct 용 선택적 요구사항 문서 ├── .github/ or .claude/ # Coding Agent 커맨드 정의 및 설정 -├── .vscode/ # 해당하는 경우 Copilot/VS Code MCP 구성 -├── .cmind/ # 생성된 리포트와 설정 파일 -└── .git/hooks/ # cmind init 이 설치하는 post-commit / post-merge (각 hook은 단 한 줄: `cmind hook `) +├── .vscode/ # 해당하는 경우 Copilot/VS Code MCP 및 상태 통합 +├── .cmind/ # 워크스페이스 마커/설정과 생성된 보고서 +└── .git/hooks/ # 선택적 post-commit / post-merge, 결정적 동기화만 수행 ``` +CoderMind의 Git hooks는 **기본적으로 꺼져 있습니다**. 결정적 동기화를 사용하려면 init/update를 실행할 **때마다** `--git-hooks` 를 지정하세요. 생략하거나 `--no-git-hooks` 를 사용하면 인식 가능한 CoderMind 관리 블록만 제거하고 다른 사용자/팀 hook 내용은 보존합니다. CoderMind가 관리하는 Git hooks는 AI나 백그라운드 작업을 실행하지 않습니다. Claude의 `SessionStart` 와 Copilot/VS Code의 `folderOpen` 은 계속 **상태만 표시**하며, Git hooks와 별개이므로 `--no-git-hooks` 의 영향을 받지 않습니다. + 전체 레이아웃과 데이터 파일 참조는 [docs/project-structure.md](docs/project-structure.md) 를 참조하세요. +### 실행 설정 + +저장소의 `recommended_provider` 및 유효한 레거시 `ai_provider` / 내장 값과 정확히 일치하는 `ai_cli_cmd` 는 권장 힌트일 뿐, **런타임 실행 권한이 아닙니다**. Init은 사용자의 명시적 선택을 저장소 밖의 `~/.cmind/execution//selection.json` 에 저장합니다. 이 선택은 정규화된 워크스페이스 경로에 연결되며, slug 기반 RPG 데이터와 별개입니다. [로컬 실행 선택](docs/configuration.md#user-local-execution-selection)을 참조하세요. + +다른 경로에 복제하거나 워크스페이스를 이동하거나 사용자가 바뀌면 다시 선택해야 합니다. 신뢰할 수 있는 CI는 프로세스 실행에 `CMIND_AI_PROVIDER` 를 사용할 수 있지만, 비 TTY 초기화에는 여전히 `--ai` 가 필요합니다. `cmind update --ai claude` 는 저장된 선택을 명시적으로 변경합니다. `--ai` 없는 update는 선택을 보존하고(없으면 없는 상태 유지), 저장소 힌트나 감지된 통합을 근거로 실행 권한을 자동 부여하지 않습니다. + +안전하지 않은 원시 명령이나 잘못된 설정이 있으면 init/update는 워크스페이스 구성이나 hook 마이그레이션 전 사전 검사에서 실패합니다. `--ai`, `--force`, 환경 변수 재정의로도 검증을 우회할 수 없습니다. [마이그레이션 체크리스트](docs/configuration.md#updating-an-existing-codermind-project)에 따라 설정을 수정하세요. + ## CoderMind 업데이트 +먼저 사용 중인 설치 방법으로 수정 사항이 포함된 CLI를 설치하세요. 예: + ```bash uv tool install cmind-cli \ --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" \ --force \ --reinstall +``` + +그런 다음 **기존 워크스페이스마다** 마이그레이션하세요. 잘못된 설정을 검토하고 수정한 뒤 아래 명령으로 명시적으로 선택합니다(Copilot을 사용하려면 `claude` 를 `copilot` 으로 변경). CLI 설치만으로는 워크스페이스의 이전 hooks가 정리되지 않습니다. -# 기존 워크스페이스 업데이트 +```bash cd +cmind update --ai claude --no-upgrade --no-git-hooks + +# 일상 업데이트는 로컬 선택을 보존하며, 없는 실행 동의를 새로 만들지 않음 cmind update ``` +인식되지 않는 레거시 hooks는 수동으로 검토하고, 관련 없는 사용자/팀 hooks는 보존하세요. 결정적 동기화가 필요한 경우에만 update마다 `--git-hooks` 를 지정합니다. [마이그레이션 체크리스트](docs/configuration.md#updating-an-existing-codermind-project)를 참조하세요. + ## 지원 플랫폼 **Coding Agent 지원**: @@ -226,11 +248,13 @@ cmind update | macOS | ⌛ | | Windows | ⌛ | +Windows 지원은 여전히 부분적입니다. 템플릿은 `sh` 전용이며 `ps` 는 지원하지 않습니다. AI 실행에는 설치된 네이티브 `.exe` 또는 지원되는 알려진 Claude/Copilot npm 진입점만 사용하며, `.cmd`, `.bat`, `.ps1` 래퍼는 실행하지 않습니다. 실제 릴리스와의 호환성을 보장하는 것은 아닙니다. 제공자의 일반 승인 절차를 따르며 광범위한 권한 우회는 제공하지 않습니다. 읽기 전용 MCP 사전 승인은 별도 설정입니다. [실행 파일 정책](docs/configuration.md#executable-and-permission-policy)과 [MCP 권한](docs/configuration.md#assistant-permissions-and-scope)을 참조하세요. + ## 문서 - [슬래시 커맨드 레퍼런스](docs/commands.md) — 모든 `/cmind.*` 커맨드의 입력, 출력, 예시. - [CLI 레퍼런스](docs/cli-reference.md) — `cmind init`, `cmind update`, `cmind check`, `cmind version` 및 모든 옵션. -- [구성](docs/configuration.md) — AI 어시스턴트 설정, MCP 등록, 훅, 자동 승인 및 트러블슈팅. +- [구성](docs/configuration.md) — 로컬 제공자 선택, MCP 권한, 선택적 hooks, 마이그레이션 및 트러블슈팅. - [프로젝트 구조](docs/project-structure.md) — CoderMind이 생성하는 파일과 디렉터리. ## 예정된 기능 diff --git a/CoderMind/README.md b/CoderMind/README.md index 8a3b6be..602617d 100644 --- a/CoderMind/README.md +++ b/CoderMind/README.md @@ -66,14 +66,14 @@ Reverse Direction: Code → RPG │ │ │ ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Existing Codebase│────────▶│ encode │──────▶│update_rpg│────────────┘ │ -│ │ │ (full) │ │ (manual │ │ -└──────────────────┘ └────┬─────┘ │ fallback)│ │ +│ │ │ (full) │ │ (explicit│ │ +└──────────────────┘ └────┬─────┘ │ update) │ │ rpg.json └──────────┘ │ - (includes dep_graph) rpg.json │ + dep_graph.json rpg.json / dep_graph.json │ │ │ └──────────────────────────────────────────┘ ▲ - │ post-commit hook normally runs incremental updates + │ opt-in post-commit hook: deterministic sync only (no AI update) MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ``` @@ -103,10 +103,12 @@ uv tool install cmind-cli --from "git+https://github.com/microsoft/RPG-ZeroRepo. cmind check # For one-time usage -uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init +uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init --ai claude ``` -Since `0.1.3`, the wheel ships the pipeline scripts and slash-command templates as packaged assets, so `cmind init` works offline (for example in air-gapped or corporate proxy environments). +Since `0.1.3`, the wheel ships the pipeline scripts and slash-command templates as packaged assets, so the template provisioning step of `cmind init` works offline. Optional initial encoding (`--encode`) may invoke AI services and require network access; self-upgrade during `cmind update` may also require network access. + +The examples use `--ai claude`; replace it with `--ai copilot` for GitHub Copilot. Interactive init can prompt if `--ai` is omitted; non-TTY init requires explicit `--ai`, even with an environment override. ## Quick Start: New Repository @@ -118,7 +120,7 @@ Use this path when you want CoderMind to turn requirements into a new codebase. 1. Initialize a new project: ```bash - cmind init my-project + cmind init my-project --ai claude cd my-project ``` @@ -149,26 +151,26 @@ Use this path when you want CoderMind to turn requirements into a new codebase. > - **Claude Code**: type `/cmind.feature_construct ...` directly in the chat — slash commands are recognised and dispatch the matching workflow. > - **GitHub Copilot CLI**: slash commands are not supported (custom agents are), so first run `/agent cmind.feature_construct` to switch to the target agent, then type `start` to run its built-in workflow. -CoderMind progressively builds `rpg.json` in the home-side runtime directory (`~/.cmind/workspaces//data/rpg.json`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Your workspace source files are not polluted. +CoderMind progressively builds `rpg.json` in the home-side runtime directory (`~/.cmind/workspaces//data/rpg.json`) and uses it to keep requirements, planning artifacts, generated code, and dependency information aligned. Bulk RPG data stays outside the repo; generated reports remain in the workspace. ## Quick Start: Existing Repository Use this path when you already have a repository and want an AI agent to understand or edit it with RPG context. > [!WARNING] -> For larger projects, `cmind init . --encode` and `/cmind.encode` can take a long time to run. As a typical example: 200 source files take about 100 minutes. +> For larger projects, `cmind init . --ai claude --encode` and `/cmind.encode` can take a long time to run. As a typical example: 200 source files take about 100 minutes. 1. Initialize CoderMind in the repository root and build the initial graph: ```bash cd existing-repo/ - cmind init . --encode # --encode builds the RPG from the current code + cmind init . --ai claude --encode # --encode builds the RPG from the current code ``` If you want to skip the confirmation prompt for a non-empty directory: ```bash - cmind init . --force --encode + cmind init . --ai claude --force --encode ``` 2. Launch your AI coding agent in the repository. @@ -177,40 +179,60 @@ Use this path when you already have a repository and want an AI agent to underst ```text /cmind.encode # rebuild the full RPG when needed - /cmind.update_rpg # manual incremental update fallback + /cmind.update_rpg # explicitly request an AI-driven incremental update /cmind.rpg_edit # graph-aware code edit ``` -4. After each commit, the git hook installed by CoderMind automatically calls the `cmind hook ` dispatcher to update the RPG and keep it aligned with code changes. If the hook fails or is skipped, run `/cmind.update_rpg` manually. +4. Request AI-driven graph updates explicitly with `/cmind.update_rpg`. Git hooks are off by default; opting in enables deterministic, foreground sync only, not AI updates or background workers. ## What happens after `cmind init` -`cmind init` does not modify your source files, **and it does not write runtime state into your workspace**. It only adds command definitions, MCP configuration, and hooks to your workspace. CoderMind runtime data (artifacts and logs) lives under the home-side directory `~/.cmind/workspaces//`, where `` is a slug derived from the workspace's absolute path (e.g. `home-hys-projects-myrepo`). +`cmind init` provisions command definitions, a workspace marker/configuration, MCP registration, and status integrations without editing your source files. Generated reports live in the workspace; bulk runtime data (RPG artifacts and logs) lives under `~/.cmind/workspaces//`, where `` is a slug derived from the workspace's absolute path (e.g. `home-hys-projects-myrepo`). ```text my-project/ ├── docs/ # Optional requirement docs for /cmind.feature_construct ├── .github/ or .claude/ # Coding Agent command definitions and settings -├── .vscode/ # Copilot/VS Code MCP configuration when applicable -├── .cmind/ # Generated reports and configuration files -└── .git/hooks/ # post-commit / post-merge installed by cmind init (each hook is one line: `cmind hook `) +├── .vscode/ # Copilot/VS Code MCP and status integration when applicable +├── .cmind/ # Workspace marker/configuration and generated reports +└── .git/hooks/ # Optional post-commit / post-merge deterministic sync only ``` +CoderMind Git hooks are **OFF by default**. Pass `--git-hooks` on **each** init/update invocation to opt into deterministic sync only. Omitting it or using `--no-git-hooks` removes recognized CoderMind-owned blocks while preserving other user/team hook content. CoderMind-managed Git hooks do not launch AI or background workers. Claude `SessionStart` and Copilot/VS Code `folderOpen` remain **status-only**, separate from Git hooks and unaffected by `--no-git-hooks`. + See [docs/project-structure.md](docs/project-structure.md) for the full layout and data file reference. +### Execution configuration + +Repository `recommended_provider` and valid legacy `ai_provider` / exact built-in `ai_cli_cmd` hints are **not runtime authority**. Init saves your explicit choice outside the repo at `~/.cmind/execution//selection.json`, bound to the canonical workspace path and separate from slug-based RPG data. See [local execution selection](docs/configuration.md#user-local-execution-selection). + +A clone at another path, a moved workspace, or a new user must choose again. Trusted CI may use `CMIND_AI_PROVIDER` for process execution; non-TTY init still requires `--ai`. `cmind update --ai claude` explicitly changes the saved choice; update without `--ai` preserves it (or leaves it absent), never authorizing execution from repository hints or detected integrations. + +Unsafe raw-command or invalid configuration makes init/update fail preflight, before provisioning or hook migration. `--ai`, `--force`, and environment overrides do not bypass validation. Correct the configuration as described in the [migration checklist](docs/configuration.md#updating-an-existing-codermind-project). + ## Updating CoderMind +First install the repaired CLI using your installation method, for example: + ```bash uv tool install cmind-cli \ --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" \ --force \ --reinstall +``` + +Then migrate **each existing workspace**: review and correct invalid configuration before running the explicit selection below (replace `claude` with `copilot` if intended). Installing the CLI alone does not clean old workspace hooks. -# Update an existing workspace +```bash cd +cmind update --ai claude --no-upgrade --no-git-hooks + +# Routine updates preserve the local choice; they do not create missing consent cmind update ``` +Review unrecognized legacy hooks manually, preserving unrelated user/team hooks. Use `--git-hooks` on each update only if deterministic sync is wanted. See the [migration checklist](docs/configuration.md#updating-an-existing-codermind-project). + ## Supported Platforms **Coding Agent support**: @@ -229,11 +251,13 @@ cmind update | macOS | ⌛ | | Windows | ⌛ | +Windows support remains partial: templates are `sh`-only (`ps` is not supported). AI launches use installed native `.exe` files or supported, known Claude/Copilot npm entries; `.cmd`, `.bat`, and `.ps1` wrappers are never executed. This is not a live-release compatibility guarantee. Normal provider approvals apply, with no broad permission bypass; read-only MCP preapproval is separate. See [executable policy](docs/configuration.md#executable-and-permission-policy) and [MCP permissions](docs/configuration.md#assistant-permissions-and-scope). + ## Documentation - [Slash command reference](docs/commands.md) — every `/cmind.*` command, inputs, outputs, and examples. - [CLI reference](docs/cli-reference.md) — `cmind init`, `cmind update`, `cmind check`, `cmind version`, and all options. -- [Configuration](docs/configuration.md) — AI assistant setup, MCP registration, hooks, auto-approval, and troubleshooting. +- [Configuration](docs/configuration.md) — local provider selection, MCP permissions, opt-in hooks, migration, and troubleshooting. - [Project structure](docs/project-structure.md) — files and directories created by CoderMind. ## Upcoming Features diff --git a/CoderMind/README.zh-CN.md b/CoderMind/README.zh-CN.md index bbbad6a..0960814 100644 --- a/CoderMind/README.zh-CN.md +++ b/CoderMind/README.zh-CN.md @@ -63,14 +63,14 @@ Reverse Direction: Code → RPG │ │ │ ┌──────────────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Existing Codebase│────────▶│ encode │──────▶│update_rpg│────────────┘ │ -│ │ │ (full) │ │ (manual │ │ -└──────────────────┘ └────┬─────┘ │ fallback)│ │ +│ │ │ (full) │ │ (explicit│ │ +└──────────────────┘ └────┬─────┘ │ update) │ │ rpg.json └──────────┘ │ dep_graph.json rpg.json / dep_graph.json │ │ │ └──────────────────────────────────────────┘ ▲ - │ post-commit hook normally runs incremental updates + │ opt-in post-commit hook: deterministic sync only (no AI update) MCP Server: search_rpg / explore_rpg / get_node_detail / list_rpg_tree ``` @@ -100,10 +100,12 @@ uv tool install cmind-cli --from "git+https://github.com/microsoft/RPG-ZeroRepo. cmind check # 一次性使用 -uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init +uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" cmind init --ai claude ``` -从 `0.1.3` 开始,wheel 会把 pipeline scripts 和 slash-command templates 作为打包资源一起发布,因此 `cmind init` 可以离线工作(例如 air-gapped 环境、公司代理环境等)。 +从 `0.1.3` 开始,wheel 会把 pipeline scripts 和 slash-command templates 作为打包资源一起发布,因此 `cmind init` 的模板配置步骤可以离线完成。可选的初始编码(`--encode`)可能调用 AI 服务并需要网络访问;`cmind update` 中的自升级也可能需要网络访问。 + +示例使用 `--ai claude`;使用 GitHub Copilot 时替换为 `--ai copilot`。交互式初始化可以在省略 `--ai` 时提示选择;非 TTY 初始化必须显式传入 `--ai`,即使已设置环境覆盖值也不例外。 ## 快速开始:新仓库 @@ -115,7 +117,7 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder 1. 初始化一个新项目: ```bash - cmind init my-project + cmind init my-project --ai claude cd my-project ``` @@ -146,26 +148,26 @@ uvx --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=Coder > - **Claude Code**:直接在对话中输入 `/cmind.feature_construct ...`,slash command 会被识别并触发对应 workflow。 > - **GitHub Copilot CLI**:不支持 slash command(但支持自定义 agent),需要先 `/agent cmind.feature_construct` 切换到目标 agent,然后输入 `start` 让它执行内置的 workflow。 -CoderMind 会渐进式地在 home-side 运行时目录(`~/.cmind/workspaces//data/rpg.json`)里创建 `rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。你的工作区源文件不会被污染。 +CoderMind 会渐进式地在 home-side 运行时目录(`~/.cmind/workspaces//data/rpg.json`)里创建 `rpg.json`,并用它把需求、规划产物、生成的代码和依赖信息保持对齐。主要 RPG 数据存放在仓库外,生成的报告仍保留在工作区内。 ## 快速开始:已有仓库 当你已经有一个仓库,希望 AI 智能体在 RPG 上下文中理解或编辑它时,使用此路径。 > [!WARNING] -> 对于较大的项目,`cmind init . --encode` 和 `/cmind.encode` 可能运行较长时间。典型例子:200 个源文件大约需要 100 分钟。 +> 对于较大的项目,`cmind init . --ai claude --encode` 和 `/cmind.encode` 可能运行较长时间。典型例子:200 个源文件大约需要 100 分钟。 1. 在仓库根目录初始化 CoderMind 并构建初始图: ```bash cd existing-repo/ - cmind init . --encode # --encode 会根据当前的代码生成 RPG + cmind init . --ai claude --encode # --encode 会根据当前的代码生成 RPG ``` 如果你想跳过非空目录的确认提示: ```bash - cmind init . --force --encode + cmind init . --ai claude --force --encode ``` 2. 在仓库里启动你的 AI 编码智能体。 @@ -174,40 +176,60 @@ CoderMind 会渐进式地在 home-side 运行时目录(`~/.cmind/workspaces/ # 图感知的代码编辑 ``` -4. 每次 commit 后,CoderMind 安装的 git hook 会自动调用 `cmind hook ` 调度器,更新 RPG,与代码变更保持对齐。如果 hook 失败或被跳过,可以手动运行 `/cmind.update_rpg`。 +4. 使用 `/cmind.update_rpg` 显式请求 AI 驱动的图更新。Git hooks 默认关闭;主动启用后也仅在前台执行确定性同步,不进行 AI 更新或启动后台任务。 ## `cmind init` 之后会发生什么 -`cmind init` 不会修改你的源文件,**也不会在你的工作区写入运行时状态**。它只在你的工作区添加命令定义、MCP 配置和 hooks,所有 CoderMind 的运行时数据(产物、日志)都放在 home-side 目录 `~/.cmind/workspaces//` 下,其中 `` 是根据工作区绝对路径生成的可读 slug(例如 `home-hys-projects-myrepo`)。 +`cmind init` 会配置命令定义、工作区标记/配置、MCP 注册和状态集成,不修改源文件。生成的报告保留在工作区内;主要运行时数据(RPG 产物和日志)存放在 `~/.cmind/workspaces//` 下,其中 `` 是根据工作区绝对路径生成的可读 slug(例如 `home-hys-projects-myrepo`)。 ```text my-project/ ├── docs/ # /cmind.feature_construct 的可选需求文档 ├── .github/ or .claude/ # AI 助手的命令定义和设置 -├── .vscode/ # 适用时的 Copilot/VS Code MCP 配置 -├── .cmind/ # 包含生成的报告和配置文件 -└── .git/hooks/ # cmind init 装的 post-commit / post-merge(每个 hook 仅一行:`cmind hook `) +├── .vscode/ # 适用时的 Copilot/VS Code MCP 和状态集成 +├── .cmind/ # 工作区标记/配置和生成的报告 +└── .git/hooks/ # 可选的 post-commit / post-merge,仅执行确定性同步 ``` +CoderMind 的 Git hooks **默认关闭**。需要确定性同步时,必须在**每次** init/update 调用中传入 `--git-hooks`。省略该选项或使用 `--no-git-hooks` 会移除可识别的 CoderMind 托管块,并保留其他用户/团队 hook 内容。CoderMind 托管的 Git hooks 不启动 AI 或后台任务。Claude 的 `SessionStart` 和 Copilot/VS Code 的 `folderOpen` 仍**仅显示状态**,独立于 Git hooks,不受 `--no-git-hooks` 影响。 + 完整的目录布局和数据文件参考见 [docs/project-structure.md](docs/project-structure.md)。 +### 执行配置 + +仓库中的 `recommended_provider` 以及有效的旧版 `ai_provider` / 精确匹配内置值的 `ai_cli_cmd` 都只是提示,**不构成运行时授权**。Init 将用户的显式选择保存在仓库外的 `~/.cmind/execution//selection.json`,绑定规范化后的工作区路径,与使用 slug 的 RPG 数据存储分离。详见[本地执行选择](docs/configuration.md#user-local-execution-selection)。 + +克隆到其他路径、移动工作区或换用户后,必须重新选择。受信任的 CI 可通过 `CMIND_AI_PROVIDER` 为进程指定执行提供方;非 TTY 初始化仍需 `--ai`。`cmind update --ai claude` 会显式更改已保存的选择;不带 `--ai` 的 update 保留原选择(未设置则仍不设置),不会根据仓库提示或检测到的集成自动授权。 + +不安全的原始命令或无效配置会让 init/update 在配置工作区或迁移 hooks 之前的预检中失败。`--ai`、`--force` 和环境覆盖值都不能绕过校验。请按[迁移清单](docs/configuration.md#updating-an-existing-codermind-project)修正配置。 + ## 更新 CoderMind +先按你的安装方式安装已包含修复的 CLI,例如: + ```bash uv tool install cmind-cli \ --from "git+https://github.com/microsoft/RPG-ZeroRepo.git#subdirectory=CoderMind" \ --force \ --reinstall +``` + +然后迁移**每个已有工作区**:先检查并修正无效配置,再运行下面的显式选择命令(如需 Copilot,将 `claude` 换成 `copilot`)。仅安装 CLI 不会清理工作区中的旧 hooks。 -# 对已有工作区进行更新 +```bash cd +cmind update --ai claude --no-upgrade --no-git-hooks + +# 日常更新保留本地选择;不会补建缺失的执行授权 cmind update ``` +手动检查无法识别的旧 hooks,保留无关的用户/团队 hooks。仅在需要确定性同步时,才在每次 update 中传入 `--git-hooks`。详见[迁移清单](docs/configuration.md#updating-an-existing-codermind-project)。 + ## 支持的平台 **Coding Agent 支持**: @@ -226,11 +248,13 @@ cmind update | macOS | ⌛ | | Windows | ⌛ | +Windows 仍为部分支持:模板仅支持 `sh`(不支持 `ps`)。AI 启动仅使用已安装的原生 `.exe` 或受支持的已知 Claude/Copilot npm 入口;从不执行 `.cmd`、`.bat` 或 `.ps1` 包装脚本。这并不保证实际发布版本的兼容性。执行遵循提供方的常规审批,不提供广泛的权限绕过;只读 MCP 预授权是独立设置。详见[可执行文件策略](docs/configuration.md#executable-and-permission-policy)和 [MCP 权限](docs/configuration.md#assistant-permissions-and-scope)。 + ## 文档 - [Slash 命令参考](docs/commands.md) —— 每一个 `/cmind.*` 命令的输入、输出和示例。 - [CLI 参考](docs/cli-reference.md) —— `cmind init`、`cmind update`、`cmind check`、`cmind version` 以及所有选项。 -- [配置](docs/configuration.md) —— AI 助手设置、MCP 注册、hook、自动审批和故障排查。 +- [配置](docs/configuration.md) —— 本地提供方选择、MCP 权限、可选 hooks、迁移和故障排查。 - [项目结构](docs/project-structure.md) —— CoderMind 创建的文件和目录。 ## 即将推出的功能 diff --git a/CoderMind/docs/cli-reference.md b/CoderMind/docs/cli-reference.md index 8c7d33c..efa4f66 100644 --- a/CoderMind/docs/cli-reference.md +++ b/CoderMind/docs/cli-reference.md @@ -4,7 +4,7 @@ This document covers the `cmind` command-line interface. Use the CLI to install ## `cmind init` -Initialize a new project from the latest template, or add CoderMind to an existing repository. +Initialize a new project using templates bundled with the installed CLI, or add CoderMind to an existing repository. Template provisioning needs no download; optional initial encoding is a separate AI operation and may use the network. ```bash cmind init [options] @@ -16,13 +16,16 @@ cmind init . [options] | Option | Description | | ------ | ----------- | -| `--ai ` | AI assistant: `copilot` or `claude` | +| `--ai ` | Explicit user-local provider and integration: `copilot` or `claude`; required for non-TTY init | | `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | | `--here` | Initialize in current directory | | `--force` | Skip confirmation for non-empty current directory | | `--no-git` | Skip git initialization | | `--no-mcp` | Skip MCP server configuration | -| `--ignore-agent-tools` | Skip checks for AI agent CLI tools | +| `--no-copilot-cli-mcp` | For Copilot, skip registration in `~/.copilot/mcp-config.json`; retain workspace MCP setup unless `--no-mcp` is also set. Does not remove existing registrations. | +| `--no-cmind-git` | Skip creating the private snapshot repo at `~/.cmind/workspaces//.git/` for this init only; does not delete or disable an existing repo or control project Git hooks. | +| `--git-hooks/--no-git-hooks` | Default OFF: remove only recognized CoderMind-managed Git hook blocks, preserving other content. Opt in to deterministic sync only. | +| `--ignore-agent-tools` | Skip availability checks for AI agent CLI tools, not AI provider validation or execution policy | | `--encode/--no-encode` | Run or skip initial RPG encoding at the end of init | | `--debug` | Show verbose diagnostic output | @@ -30,43 +33,76 @@ cmind init . [options] | Agent | Folder | Description | Status | | ----- | ------ | ----------- | ------ | -| `copilot` | `.github/`, `.vscode/` | GitHub Copilot | Verified | -| `claude` | `.claude/` | Claude Code | Verified | +| `copilot` | `.github/`, `.vscode/` | GitHub Copilot | CLI integration; known npm adapter layouts | +| `claude` | `.claude/` | Claude Code | CLI integration; known npm adapter layouts | -CoderMind currently supports only **GitHub Copilot** and **Claude Code** in the CLI. Additional agents may be adapted in future releases. +CLI `--ai` selection exposes only **GitHub Copilot** and **Claude Code**. The runtime policy retains 11 historical provider IDs; the other nine have native-executable/scaffold compatibility only, no Windows npm adapters or verified integration support. See the [provider enum](configuration.md#provider-enum-and-legacy-compatibility). Adapter fixtures are not live npm-release tests; see the [Windows executable/npm requirements](configuration.md#executable-and-permission-policy). + +### AI configuration and execution + +New workspace configuration uses `[cmind].recommended_provider`: a tracked hint and workspace-discovery marker, **never execution authority**. Valid existing hints, including legacy `ai_provider` and exact built-in `ai_cli_cmd`, are preserved byte-for-byte. Only one hint key is allowed; raw commands are never executed. `--ai` does not overwrite a valid hint or import it into local state. + +Runtime authority is P1 explicit trusted `LLMClient(tool=...)` (exact legacy command), P2 `CMIND_AI_PROVIDER` (enum) **or** legacy `CMIND_AI_CLI_CMD` (exact legacy command; mutually exclusive), then P3 user-local selection. Every consulted source must validate; malformed values fail closed without fallback. Repository hints are always preflighted, **even with environment/constructor overrides**. Legacy release-baked commands are validated but never authorize a call. No paths, extra flags, quoting, whitespace variants, or shell syntax are accepted as provider values. + +Local consent lives under the resolved home directory at `~/.cmind/execution//selection.json`, separate from RPG metadata at `~/.cmind/workspaces//.meta.toml`. Its exact schema requires integer `schema_version: 1`, matching canonical `workspace` identity, and valid `ai_provider`. A clone/move/new user needs explicit selection; copying metadata or selection records is not a consent migration mechanism. See [local storage and validation](configuration.md#user-local-execution-selection). + +Init requires `--ai` or an interactive provider choice independent of the recommendation. Non-TTY init without `--ai` fails before provisioning, even if the environment selects a provider. The explicit choice is saved only after provisioning and Git hook reconciliation succeed. Git hook cleanup/installation or local-save failure means no success message or initial encode; a failed atomic save preserves the prior selection. Separate `SessionStart` / `folderOpen` status integration installation remains best-effort. + +Execution is shell-free with absolute external executables. Windows prioritizes direct `.exe` files; the adapter recognizes only exact Claude/Copilot npm package names and fixed native/JS entries, with canonical paths outside workspace/cwd and external absolute `node.exe` for JS. Wrappers are neither parsed nor executed; arbitrary user scripts and signature/authenticity guarantees are not supported. See the [exact layouts and trust boundary](configuration.md#executable-and-permission-policy). + +The default Claude `--dangerously-skip-permissions` and Copilot `--allow-all` flags remain removed, with no opt-in bypass. Normal permissions may require approval or halt noninteractive runs. Both init and update preflight repository configuration before provisioning or hook migration; `--ai`, `--force`, and `--ignore-agent-tools` do not auto-trust invalid hints. [Trusted CI process selection](configuration.md#trusted-ci-process-selection) needs no persistent local consent, but still requires normal workspace lookup and validation; it is not a sandbox for untrusted PR workflows. + +On configuration, authentication, access, or approval rejection, stop and show the exact error. Do not automatically retry, run init/update, rewrite configuration or local consent, or grant trust/permission overrides; wait for the user's explicit resolution and retry decision. ### Examples ```bash -cmind init my-project +cmind init my-project --ai claude cmind init my-project --ai claude --script sh -cmind init . --force -cmind init . --encode -cmind init . --force --encode +cmind init . --ai claude --force +cmind init . --ai claude --encode +cmind init . --ai claude --force --encode cmind init --here --ai copilot +cmind init --here --ai claude --git-hooks ``` ## `cmind update` -Update CoderMind template files, scripts, command definitions, MCP configuration, gitignore rules, and hooks in an existing project. The AI assistant is auto-detected from existing project configuration when possible. +Refresh workspace command definitions, MCP configuration, and gitignore rules from the installed CLI's bundle, and reconcile hooks in an existing project. Pipeline scripts stay in the installed wheel, not workspace copies. Update attempts a best-effort CLI self-upgrade by default, which may use the network; `--no-upgrade` skips it. Git hooks are OFF unless explicitly requested on this invocation. + +With `--ai`, save that explicit provider locally only after provisioning and hook reconciliation succeed; valid repository hints remain unchanged. Without `--ai`, preserve the local record byte-for-byte (or leave it absent), even when an integration is auto-detected or chosen interactively. Template selection prefers a supported local provider, then detected folders, then an interactive integration-only choice. Malformed local state fails when consulted; non-TTY update without a determinable integration requires `--ai`. Neither detection nor recommendations create consent, and local-save failure must not report success. ```bash cmind update cmind update --ai claude cmind update --no-mcp cmind update --no-upgrade +cmind update --no-upgrade --no-git-hooks +cmind update --git-hooks ``` ### Options | Option | Description | | ------ | ----------- | -| `--ai ` | AI assistant, auto-detected if not specified | +| `--ai ` | Explicitly save `copilot` or `claude` locally after success and refresh its integration; omission refreshes integrations without changing local consent | | `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | | `--no-upgrade` | Skip the default-on CLI self-upgrade and only sync workspace files. | | `--no-mcp` | Skip MCP server configuration | +| `--no-copilot-cli-mcp` | For Copilot, skip registration in `~/.copilot/mcp-config.json`; retain workspace MCP setup unless `--no-mcp` is also set. Does not remove existing registrations. | +| `--no-cmind-git` | Skip backfilling a missing private snapshot repo at `~/.cmind/workspaces//.git/`; leaves existing snapshot repos unchanged and does not control project Git hooks. | +| `--git-hooks/--no-git-hooks` | Default OFF: remove only recognized CoderMind-managed Git hook blocks, preserving other content. `--git-hooks` installs deterministic sync only; repeat on each init/update to retain it. | | `--debug` | Show verbose diagnostic output | +### Git hooks and workspace migration + +- Default / `--no-git-hooks`: remove recognized managed `pre-commit`, `post-commit`, and `post-merge` blocks, including recognized legacy bodies; preserve unrelated or unrecognized content. +- `--git-hooks`: install foreground, deterministic `post-commit` / `post-merge` sync only; remove retired managed `pre-commit` blocks. Post-commit background LLM updates are removed entirely, not an optional mode. +- LLM-driven graph updates require explicit `cmind script update_graphs.py update-rpg --json` or `/cmind.update_rpg`, outside hook context. +- Git hook cleanup/installation failures abort init/update before saving a new local selection or initial encoding. Installation of Claude `SessionStart` and Copilot / VS Code `folderOpen` status-only integrations remains best-effort; `--no-git-hooks` does not disable them. + +Upgrade the CLI, then reconcile **each** old workspace. To establish local consent, use `cmind update --ai claude --no-upgrade --no-git-hooks` (or `copilot`); omit `--ai` only to preserve existing local state, including absence. A wheel upgrade alone does not clean legacy inline-script hooks. Valid provider-only hints may stay tracked; no `git rm` is required. Invalid raw commands fail preflight even with environment overrides: manually remove them or replace them with one valid `recommended_provider`, then explicitly select a provider. Review unrecognized hooks, including `core.hooksPath`, without deleting unrelated content. See the [upgrade checklist](configuration.md#updating-an-existing-codermind-project). + ### Auto-upgrade behaviour Since the global-install layout, `cmind update` performs a **best-effort silent self-upgrade by default** when the install source is safe to refresh (git+URL or PyPI). After upgrading the CLI it re-executes itself once to continue the workspace sync with the new code. Editable installs, local-file installs, and unknown sources are skipped silently. @@ -76,10 +112,12 @@ Since the global-install layout, `cmind update` performs a **best-effort silent ### Provisioning sources -As of `0.1.4`, `cmind init` and `cmind update` provision exclusively +`cmind init` and `cmind update` provision exclusively from the **packaged assets bundle** shipped inside the installed -`cmind-cli` wheel (under `cmind_cli/core_pack/`). No network access -is required at provisioning time. +`cmind-cli` wheel (under `cmind_cli/core_pack/`). No network access +is required for this template-copying step, not a guarantee that the +entire init/update workflow is offline: encoding may call a provider, +and update's default self-upgrade may fetch packages. To pick up newer prompts and templates, upgrade the CLI itself (e.g. `uv tool upgrade cmind-cli`). `cmind update` does this @@ -100,6 +138,8 @@ prints a tree of which ones are available. Run this after installation to confirm the environment is ready, or whenever a pipeline step complains about a missing tool. +This availability check does not replace execution-policy validation: a detected Windows wrapper alone is insufficient. See the [native/npm requirements](configuration.md#executable-and-permission-policy). It also does not establish user-local consent. + ## `cmind version` Display version and system information. @@ -136,10 +176,16 @@ and absolute paths are rejected for safety. ```bash cmind script smoke_test.py --json cmind script rpg_edit/validate.py +cmind script update_graphs.py status +cmind script update_graphs.py update-rpg --json cmind script --list cmind script --where mcp_server.py ``` +`status` displays read-only graph status. `update-rpg` explicitly requests an LLM-driven update against `HEAD~1`; it requires an existing RPG and at least two commits, a trusted runtime provider selection, and normal provider permissions. Repository recommendations alone are insufficient. Neither enabling Git sync hooks nor opening a session requests this update. + +`HEAD~1` must resolve locally, including in shallow clones. The comparison uses the current working tree, so uncommitted changes are allowed; it is not limited to committed changes. It does not automatically select the graph's last synced commit. If the graph spans older history or another branch, ask the user whether to perform a full `/cmind.encode` instead; do not rebuild or modify history automatically. + The slash-command templates installed by `cmind init` (in `.claude/commands/` or `.github/agents/`) all use `cmind script …` under the hood, so AI agents invoke the pipeline through the same diff --git a/CoderMind/docs/commands.md b/CoderMind/docs/commands.md index 1d984ea..e637e0c 100644 --- a/CoderMind/docs/commands.md +++ b/CoderMind/docs/commands.md @@ -8,6 +8,16 @@ CoderMind provides 15 slash commands that work in three paths: > **Note on data paths.** Throughout this document, paths shown as `.cmind/data/...` and `.cmind/logs/...` are stable logical names. The actual files live **outside the workspace** under `~/.cmind/workspaces//{data,logs}/`, where `` is a slug-based identifier and may include an overflow `-` suffix, so that runtime artefacts never enter the user's git repository. Reports (`rpg.html`, review HTML, etc.) stay in the workspace at `/.cmind/reports/` because they are small user-facing artefacts users may want to commit. Run `cmind version` from inside the workspace to see the resolved Data / Logs paths. See [project-structure.md](project-structure.md) for the full layout. +## AI Execution Prerequisites + +AI-backed stages require an explicit trusted constructor choice, a trusted process selection (`CMIND_AI_PROVIDER` or exact legacy `CMIND_AI_CLI_CMD`), or a valid user-local workspace selection, in that order. Tracked `recommended_provider` and preserved legacy `ai_provider`/`ai_cli_cmd` values are **hints and workspace markers only**; neither they, release-baked defaults, detected integrations, nor RPG metadata authorize execution. Consulted malformed configuration fails closed, and invalid repository hints still fail preflight with environment overrides. + +After a clone, move, or change of user, explicitly select a provider with `cmind init --here --ai claude` or `cmind update --ai claude` (or `copilot`). Init also accepts an interactive provider choice; non-TTY init requires `--ai`. The local choice is saved only after Git hook reconciliation succeeds; Git hook or local-save failure must not report success or start initial encoding. Separate startup status integration installation is best-effort. Update without `--ai` preserves local state, including absence, even if it detects an integration. See [user-local identity and storage](configuration.md#user-local-execution-selection), [trusted CI without local persistence](configuration.md#trusted-ci-process-selection), and [Windows executable/npm rules](configuration.md#executable-and-permission-policy). Normal provider approvals still apply; permission-bypass flags remain removed. + +### Authorization errors: stop, do not auto-repair + +Configuration, authentication, access, or approval blocks override all automatic retry, resume, and continuous-operation instructions below. Surface the exact error and any reported diagnostic artifact path, preserve existing artifacts, and pause for the user's decision. Do not treat the block as a checkpoint, run init/update automatically, rewrite configuration or local selection, set trust/environment overrides, or grant permissions. Resume only after the user resolves the blocker through normal controls and explicitly requests another attempt. + ## Command Overview ### Phase 1: Feature Specification @@ -51,7 +61,7 @@ CoderMind provides 15 slash commands that work in three paths: | Command | Description | | ------- | ----------- | | `/cmind.encode` | Encode an existing repository into `.cmind/data/rpg.json` | -| `/cmind.update_rpg` | Manually run incremental RPG update when the automatic hook is skipped or fails | +| `/cmind.update_rpg` | Explicitly run an LLM-driven incremental RPG update in the foreground | Both directions produce the same RPG structure at `.cmind/data/rpg.json`, enabling AI agents to query the graph via the **MCP server** (`search_rpg`, `explore_rpg`, `get_node_detail`, `list_rpg_tree`). See [configuration.md](configuration.md) for MCP details. @@ -509,6 +519,8 @@ Encode the current repository into an RPG from scratch. If `rpg.json` already exists, the command asks whether to full re-encode, switch to `/cmind.update_rpg`, or quit. +On an error, preserve the existing graph and any diagnostic/report artifact, show the exact error and reported path, and ask the user how to proceed. Do not automatically delete the graph or rebuild it as recovery. + **Example:** ```text @@ -519,9 +531,11 @@ If `rpg.json` already exists, the command asks whether to full re-encode, switch ### `/cmind.update_rpg` -Manually trigger an incremental RPG update when the automatic hook did not run or when the user wants an immediate foreground update. +Explicitly request an LLM-driven incremental RPG update in the foreground. -Under normal use, CoderMind installs a post-commit hook that updates the RPG in the background after each commit. This command is the manual fallback. +Git hooks are off by default; opting in installs deterministic sync only. Post-commit background LLM updates have been removed, so this command (or `cmind script update_graphs.py update-rpg --json`) is the explicit update path, not a fallback for an automatic AI job. Normal provider permissions apply and may require approval or halt a noninteractive run. + +Invoking this command requests the update but does not import execution consent from the tracked recommendation. The [AI execution prerequisites](#ai-execution-prerequisites) still apply. **Input:** existing `.cmind/data/rpg.json` and a git repository with at least two commits @@ -530,15 +544,17 @@ Under normal use, CoderMind installs a post-commit hook that updates the RPG in **Process:** 1. **Pre-check** — runs `cmind script rpg_encoder/check_encode.py --json` and stops if `rpg.json` is missing or corrupt. -2. **Commit baseline check** — verifies `HEAD~1` exists. If there is no previous commit, run `/cmind.encode` instead. -3. **Incremental update** — runs `cmind script update_graphs.py update-rpg --json`, comparing the current workspace against `HEAD~1`, the same baseline used by the hook. +2. **Commit baseline check** — verifies `HEAD~1` resolves locally, including in shallow clones. If unavailable, stop and let the user choose whether to run `/cmind.encode` instead. +3. **Incremental update** — runs `cmind script update_graphs.py update-rpg --json`, comparing the current workspace against `HEAD~1`. 4. **Report result** — displays node/edge deltas, functional areas, alignment status, and output path. +Uncommitted working-tree changes are allowed; the comparison is not limited to committed changes. `HEAD~1` is a fixed baseline, not necessarily the graph's last successful update. After several unsynced commits or a branch switch, ask the user whether to full re-encode instead of assuming this incremental update covers the missing history. Do not stash, commit, fetch history, or rebuild automatically to satisfy prerequisites. + Use this command when: -- The post-commit hook failed or was skipped. -- `.cmind/logs/update_rpg.log` shows an error. -- The RPG seems stale and you want to force a synchronous update. +- Code changes, committed or still in the working tree, require an LLM-driven feature graph refresh against `HEAD~1`. +- The RPG seems stale and you want to inspect an update immediately. +- A previous explicit update failed and its configuration or approval issue has been resolved. **Example:** @@ -561,7 +577,7 @@ CoderMind registers an MCP server named `rpg-tools` so AI agents can query `.cmi If `.cmind/data/rpg.json` is not available yet, the tools return an `rpg_unavailable` response that asks the agent to run `/cmind.encode`. -See [configuration.md](configuration.md) for MCP registration, auto-approval, hooks, and initialization options. +See [configuration.md](configuration.md) for local provider selection, MCP permissions, opt-in sync hooks, and initialization/migration options. --- diff --git a/CoderMind/docs/configuration.md b/CoderMind/docs/configuration.md index 8272f35..bfcd14c 100644 --- a/CoderMind/docs/configuration.md +++ b/CoderMind/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -This document covers CoderMind configuration that is useful after installation: AI assistant setup, MCP registration, auto-approval, hooks, and initial encoding. +This document covers AI provider selection, assistant permissions, MCP registration, hooks, and workspace upgrades. > **Data paths.** References below such as `.cmind/data/rpg.json` and `.cmind/logs/...` are logical names. Runtime files actually live under `~/.cmind/workspaces//{data,logs}/` so they stay outside your git repo, where `` is the slug-based workspace identifier used by the home-side store (with an optional `-` suffix when needed). Reports stay in the workspace at `/.cmind/reports/`. The MCP server, hooks, and pipeline scripts all resolve the home-dir location automatically from the workspace root. Run `cmind version` from inside the workspace to see the resolved Data / Logs paths; see [project-structure.md](project-structure.md) for the full layout. @@ -8,51 +8,60 @@ This document covers CoderMind configuration that is useful after installation: CoderMind slash commands are executed by an AI coding agent. Before running `cmind init`, install and authenticate at least one supported AI assistant CLI. -Currently verified assistants: +CLI integration choices (not a claim that live npm releases were tested): | Agent | `--ai` value | Generated configuration | Requirement | | ----- | ------------ | ----------------------- | ----------- | | GitHub Copilot | `copilot` | `.github/`, `.vscode/` | Copilot CLI available and authenticated | | Claude Code | `claude` | `.claude/` | Claude Code CLI available and authenticated | -Use `cmind check` to verify required local tools. +Use `cmind check` to check local tool availability. AI execution also applies the stricter [executable policy](#executable-and-permission-policy) below. ```bash cmind check ``` -If the selected AI assistant is not found, install and authenticate it, then rerun `cmind init` or `cmind update`. +If the selected AI assistant is not found, stop and report the exact error. The user must choose whether to install/authenticate it or change their explicit selection; agents must not automatically run init/update as recovery. ## Workspace Configuration (`.cmind/config.toml`) -Since `0.1.3`, every workspace owns a `.cmind/config.toml` file that records which AI CLI command the pipeline scripts should invoke. This decouples the scripts from a single AI at packaging time — the same packaged scripts now serve any AI you pick. +The workspace's `.cmind/config.toml` is a **workspace-discovery marker and provider recommendation only**, never execution authority. Keeping this provider-only hint tracked is intentional; no `git rm` is needed. It cannot select an executable, authorize AI calls, or grant auto-approval. ```toml # .cmind/config.toml [cmind] -ai_cli_cmd = "claude" +recommended_provider = "claude" ``` -The file is created automatically by `cmind init --ai `. Edit it any time to switch the workspace to a different AI; no need to re-run `init`. +`cmind init` and `cmind update` create this file if missing and preserve valid existing files byte-for-byte. Legacy `ai_provider` and exact built-in `ai_cli_cmd` values remain valid **hints**, never authority. Only one of the three keys is allowed. Changing a recommendation does not change execution consent; an explicit user choice is independent of the hint and is never imported from it. ### Resolution priority -When a pipeline script (or a hook, or the MCP server) needs to invoke the AI CLI, it resolves the command via the following chain. The first non-empty value wins: +AI calls use the closed policy in [../scripts/common/ai_cli_policy.py](../scripts/common/ai_cli_policy.py): -| # | Source | Use case | -| - | ------ | -------- | -| P1 | `LLMClient(tool="...")` constructor argument | Programmatic override (rare) | -| P2 | `CMIND_AI_CLI_CMD` environment variable | CI runs, one-off experiments | -| P3 | `.cmind/config.toml` `[cmind].ai_cli_cmd` | Normal default (per workspace) | -| P4 | Release-zip baked-in literal | Legacy workspaces provisioned before v0.1.4 | +| # | Source | Accepted value | +| - | ------ | -------------- | +| P1 | Explicit, trusted `LLMClient(tool="...")` constructor argument | Exact legacy command from the table below; not copied from repository metadata | +| P2 | `CMIND_AI_PROVIDER` **or** legacy `CMIND_AI_CLI_CMD` | Provider enum for the former; exact legacy command for the latter; mutually exclusive | +| P3 | User-local workspace selection | Validated provider and workspace identity from the record described below | -If all four resolve to empty, the next `LLMClient.generate()` call raises a `RuntimeError` instructing the user to run `cmind init` or set the env var. +Every consulted configuration must be valid: unknown, non-string, empty, malformed, unreadable, or conflicting settings fail closed, with **no fallthrough**. Workspace configuration is always validated before resolution, **even with a constructor or environment override**. Init/update also preflight it before provisioning, hook migration, or self-upgrade. Nonempty legacy release-baked commands are validated but **never authorize execution or provide a fallback**. -### Supported AI CLI commands +A higher-priority authority does not read or rewrite lower-priority local state. If no authority is present, `LLMClient.generate()` reports a configuration error despite any workspace or baked recommendation. -The values written to `ai_cli_cmd` mirror the per-AI substitutions performed by the GitHub release-zip CI: +### User-local execution selection -| `--ai` value | `ai_cli_cmd` | +Explicit choices are saved under `Path.home().resolve() / ".cmind" / "execution" / / "selection.json"`. The hash is the full SHA-256 of the UTF-8 canonical workspace identity: resolve symlinks, normalize Windows extended-path prefixes, then apply `os.path.normcase`. It is not the slug used by the RPG data store. + +The JSON record has exactly three fields: `schema_version` (integer `1`), `workspace` (that exact canonical identity), and `ai_provider` (a valid provider enum). Invalid schemas or mismatched identities fail closed when read; redirected/symlinked state paths and storage inside the workspace are rejected. This record is separate from RPG data and provisioning metadata at `~/.cmind/workspaces//.meta.toml`; copying those files cannot authorize execution. Do not copy or hand-edit selection records to migrate consent. + +A clone at another path, a moved workspace, or a new user needs an explicit selection (or trusted process environment). There is no automatic import from recommendations, detected integrations, or metadata. Use `cmind init --here --ai claude` or, for an existing CoderMind workspace, `cmind update --ai claude`; choose `copilot` instead if intended. + +### Provider enum and legacy compatibility + +The policy accepts exactly these 11 historical providers, case-sensitively. Legacy command values must match the right column exactly: no executable paths, extra flags, quote characters inside the value, leading/trailing or altered whitespace, or shell syntax. The fixed arguments and single spaces shown below are the only accepted legacy forms. + +| Provider enum (`recommended_provider`, `ai_provider`, `CMIND_AI_PROVIDER`) | Exact legacy form (`ai_cli_cmd`, `CMIND_AI_CLI_CMD`, `tool`, baked command) | | ------------ | ------------ | | `copilot` | `copilot` | | `claude` | `claude` | @@ -66,11 +75,35 @@ The values written to `ai_cli_cmd` mirror the per-AI substitutions performed by | `opencode` | `opencode run` | | `amp` | `amp --execute` | -Only `copilot` and `claude` are currently verified end-to-end; the others are scaffolded but may need integration adjustments. +Only `copilot` and `claude` are exposed by CLI `--ai` selection and have Windows npm adapters. The other nine retain historical native-executable/scaffold integration only, not verified integration support or npm adapters. Enum acceptance and mocked adapter coverage are not live-release compatibility guarantees. + +### Executable and permission policy -### Other config keys +- CoderMind builds argv internally and invokes the provider without a shell. It resolves an absolute executable using only absolute `PATH` entries outside both the workspace and current working directory, checking resolved symlink targets too. Empty and relative entries are ignored; installed tools and those external `PATH` directories remain trusted. +- On Windows, direct provider `.exe` files take priority across eligible `PATH` directories. The fallback adapter recognizes only the Claude/Copilot npm layouts below, under an external search directory's `node_modules`. It never reads, parses, or executes `.cmd`, `.bat`, or `.ps1` wrappers, and does not accept arbitrary user scripts or invoke `npm`/`npx`. +- The adapter requires an exact manifest `name` and a `bin` object containing exactly the provider key and an allowlisted entry. Package, manifest, entry, and interpreter paths must resolve canonically outside the workspace and current directory; manifest/entry links must stay inside the package. JS entries require an external `node.exe`, returned as an absolute path from the same eligible search directories. Native Claude needs no Node. +- The default Claude `--dangerously-skip-permissions` and Copilot `--allow-all` arguments have been removed, with **no new opt-in bypass**. Standard provider permissions apply: a tool action may require user approval or halt a noninteractive workflow. Review approvals through the provider's normal controls; provider selection grants none. +- Product Git calls (including hook sync and private snapshots) also resolve external absolute executables, excluding the enclosing workspace/current checkout and redirected paths. Windows product tools require native `.exe` files. Managed Git/Claude hooks pin the current interpreter and installed CLI bootstrap; VS Code status uses a process task, not a bare `cmind` shell command. +- Initial encoding uses the same trusted asset resolution as `cmind script` (wheel assets or the explicitly installed editable source tree). A legacy encoder under the target workspace is never an execution fallback. -The `[cmind]` table currently holds only `ai_cli_cmd`. Future releases will add timeouts, retry budgets, and model overrides under the same namespace; older keys remain forward-compatible. +| Exact npm package name | Exact `bin` key | Allowed entry and public manifest evidence | +| --- | --- | --- | +| `@anthropic-ai/claude-code` | `claude` | `bin/claude.exe` ([2.1.278](https://unpkg.com/@anthropic-ai/claude-code@2.1.278/package.json)); `cli.js` ([2.0.0](https://unpkg.com/@anthropic-ai/claude-code@2.0.0/package.json)) | +| `@github/copilot` | `copilot` | `index.js` ([0.0.369](https://unpkg.com/@github/copilot@0.0.369/package.json)); `npm-loader.js` ([1.0.87](https://unpkg.com/@github/copilot@1.0.87/package.json)) | + +These versioned manifests document layouts, not version pins, compatibility guarantees, or locally tested installations. The [adapter](../scripts/common/windows_ai_cli.py) checks filesystem paths and metadata, **not signatures or publisher authenticity**. External installations, Node, and loader dependencies remain user-trusted; these checks do not secure a tampered installation. + +### Trusted CI process selection + +For an already initialized workspace (or a checkout with its valid workspace marker), a trusted POSIX CI step can select a provider for that process and its children only: + +```bash +CMIND_AI_PROVIDER=claude cmind script rpg_encoder/run_encode.py --json +``` + +This changes no system setting and does not require persisting a local selection. Normal pipeline workspace-marker lookup still applies; this example does not bootstrap an uninitialized checkout. A noninteractive `cmind init` still requires `--ai`, even with the environment set. Invalid repository hints still fail preflight, and normal executable and provider-permission rules still apply. + +Only a trusted operator/workflow may supply execution authority. Running untrusted upstream or pull-request workflows is not an authorization bypass or sandbox; the provider policy does not make arbitrary repository code safe. ## Initialization Options @@ -81,16 +114,17 @@ cmind init my-project --ai claude cmind init my-project --ai copilot ``` -If `--ai` is omitted in an interactive terminal, CoderMind prompts for a supported assistant. +`--ai` selects both the integration and the provider to save locally. If omitted in an interactive terminal, CoderMind asks for that explicit choice, independently of the recommendation. Non-TTY init without `--ai` fails early, before provisioning; environment overrides or detected integrations do not satisfy this init requirement. + +The local choice is saved atomically only after successful provisioning and **Git hook reconciliation**, before the success message or optional initial encode. A Git hook cleanup/installation error or local-save error aborts init/update without reporting success; init does not proceed to encoding. A failed atomic replacement preserves the previous selection. Installation of the separate `SessionStart` / `folderOpen` status integrations is best-effort and does not have this fatal-error contract. ### Script type ```bash cmind init my-project --script sh -cmind init my-project --script ps ``` -`sh` installs POSIX shell-oriented command snippets. `ps` installs PowerShell-oriented snippets. +`sh` installs POSIX shell-oriented command snippets. `ps` (PowerShell) is not yet supported; this is separate from Windows native/npm executable resolution for AI calls. ### MCP registration @@ -109,6 +143,12 @@ cmind update --no-mcp Skipping MCP means the slash-command pipeline still works, but the AI assistant will not get the `rpg-tools` graph-query tools automatically. +For Copilot, `--no-copilot-cli-mcp` skips registration in `~/.copilot/mcp-config.json` while retaining workspace MCP setup. Neither skip option removes existing registrations or permissions. + +### Private snapshot repository + +By default, init creates the private snapshot repo at `~/.cmind/workspaces//.git/`; update backfills it if missing. `--no-cmind-git` skips that creation for the current invocation. It does not delete or disable an existing snapshot repo, change the local provider selection, or control project Git hooks. Project `--no-git` and opt-in `--git-hooks` are separate settings. + ### Initial encode The MCP tools query `.cmind/data/rpg.json`. For existing codebases, that file is created by the encoder. @@ -116,8 +156,8 @@ The MCP tools query `.cmind/data/rpg.json`. For existing codebases, that file is `cmind init` supports: ```bash -cmind init --here --encode -cmind init --here --no-encode +cmind init --here --ai claude --encode +cmind init --here --ai claude --no-encode ``` Behavior: @@ -154,10 +194,10 @@ For Claude Code, CoderMind writes command definitions and settings under `.claud ```text .claude/ ├── commands/ # /cmind.* command definitions -└── settings.json # permissions and MCP auto-approval +└── settings.json # status integration, read-only MCP rule, existing permissions ``` -The settings file grants project-scoped permissions needed by CoderMind commands, including access to the `rpg-tools` MCP server. Review `.claude/settings.json` if your team wants stricter local permission prompts. +The status integration adds the project-scoped `mcp__rpg-tools` allow rule for read-only graph queries; it is not a blanket grant for Bash, Write, Edit, or provider execution. Review `.claude/settings.json` and any pre-existing permissions separately. MCP server registration itself is in the workspace's `.mcp.json`. ### GitHub Copilot / VS Code @@ -173,59 +213,68 @@ For Copilot, CoderMind writes agent instructions under `.github/` and VS Code MC Open the project in VS Code after initialization so the workspace MCP configuration is available to Copilot. -## Auto-approval and Scope +## Assistant Permissions and Scope -CoderMind pre-authorizes the `rpg-tools` MCP server where the selected assistant supports project-scoped permissions. The goal is to avoid prompting on every graph query during chat. +Provider configuration is not a permission grant. Claude's status integration separately adds a project-scoped `mcp__rpg-tools` allow rule for the four read-only graph queries; Copilot / VS Code manages MCP approvals through its own controls. Review generated and existing assistant permissions separately: removing CLI bypass flags does not revoke settings you already granted. -Scope rules: +- `--no-mcp` skips MCP registration, not status integrations or blanket removal of existing permissions. +- Copilot initialization/update also registers MCP in the user's `~/.copilot/mcp-config.json` by default; Copilot CLI does not read `.vscode/mcp.json`. Use `--no-copilot-cli-mcp` to skip that registration while retaining workspace MCP setup. -- Configuration is written into the project that ran `cmind init` or `cmind update`. -- User-level assistant settings are not modified. -- Passing `--no-mcp` skips MCP registration and related auto-approval entries. +The separate Release ZIP packagers still generate a broader Claude permissions template, including Bash, Write, and Edit. The current bundle-based `cmind init/update` path does not use that template; unpacking a ZIP manually or retaining old settings is different. Removing CLI bypass flags does not restrict permissions already granted by those files. Review them separately rather than assuming migration revokes them. ## Git Hooks and Incremental Updates -CoderMind installs local git hooks to keep the RPG aligned with code changes. +Git hooks are **OFF by default** for both `cmind init` and `cmind update`. -The important hook behavior is: +- Omit the option or pass `--no-git-hooks` to remove only recognized CoderMind-managed `pre-commit`, `post-commit`, and `post-merge` blocks, including recognized legacy bodies. Other hook content is preserved; unfamiliar or incomplete old bodies require manual review. +- If a hook cannot be read or cleaned, reconciliation still attempts the other hooks, then fails explicitly. Malformed bytes are preserved; init/update must not report successful migration or proceed to encoding after that failure. +- Pass `--git-hooks` on **each** init/update invocation to install deterministic, foreground `post-commit` / `post-merge` sync only. Retired managed `pre-commit` blocks are removed in either mode. Sync writes `hooks.log` under the home-side Logs directory shown by `cmind version`. +- The post-commit background LLM update has been removed entirely. Git hooks do not launch AI or background workers; sync does not replace an LLM-driven feature graph update. -- After commits, CoderMind can run an incremental update in the background. -- The update refreshes `.cmind/data/rpg.json` and `.cmind/data/dep_graph.json`. -- Logs are written to `.cmind/logs/update_rpg.log`. +```bash +cmind init --here --ai claude --no-git-hooks +cmind update --git-hooks +cmind update --no-git-hooks +``` -Manual fallback: +For LLM-driven updates, explicitly invoke the following from the workspace, outside Git hooks, or use `/cmind.update_rpg`: -```text -/cmind.update_rpg +```bash +cmind script update_graphs.py update-rpg --json ``` -Use `/cmind.update_rpg` when: +This requires an existing RPG and a resolvable `HEAD~1` (at least two commits with the parent available locally). It compares that commit with the **current working tree**, including uncommitted changes; a dirty working tree is not by itself a blocker. The baseline is fixed, not the graph's last successful update, so a graph stale across several commits or a branch switch may need a user-approved full `/cmind.encode`. If the graph or history is missing, stop and let the user choose how to proceed; do not encode or alter history automatically. -- The hook failed. -- The hook was skipped. -- You want to force a foreground update and inspect the result. +### Session and workspace startup status -If the RPG seems significantly stale or corrupted, run a full encode instead: - -```text -/cmind.encode -``` +Claude's `SessionStart` integration and Copilot / VS Code's `folderOpen` task remain **status-only**: they run `cmind script update_graphs.py status` to display graph status and guidance, without an LLM update. They are not Git hooks and are not disabled by `--no-git-hooks`. Their installation is best-effort: a status integration warning does not abort init/update, unlike Git hook reconciliation or local-selection save failures. ## Updating an Existing CoderMind Project -Run `cmind update` from the project root to refresh scripts, command definitions, MCP configuration, gitignore rules, and hooks. +Upgrade the installed CLI **and reconcile every existing workspace**. Upgrading the wheel alone does not rewrite old hook files, especially legacy inline-script hooks. -```bash -cmind update -cmind update --ai claude -cmind update --no-upgrade -cmind update --no-mcp -``` +1. Upgrade `cmind-cli` using your installation method (for example, `uv tool upgrade cmind-cli`). Verify the installed CLI with `cmind version`; do not rely on a workspace update's best-effort self-upgrade alone. +2. Review each workspace's `.cmind/config.toml`. Valid provider-only hints, including exact legacy `ai_provider`/`ai_cli_cmd`, may remain tracked and are preserved; no `git rm` is needed. Old raw commands are never executed. Manually remove unsupported command settings or replace them with one valid `recommended_provider`. **Invalid configuration fails preflight before provisioning, self-upgrade, or hook migration**, even with `--ai`, `--force`, or an environment override. +3. From each workspace root, explicitly choose the intended provider with `cmind update --ai claude --no-upgrade --no-git-hooks` (or `--ai copilot`); for first-time setup use `cmind init --here --ai ...`. This refreshes integrations, reconciles hooks, then saves the explicit choice locally. Use `--git-hooks` instead only if deterministic sync is wanted. No choice is imported from workspace or RPG metadata. +4. Review remaining hooks in the active hooks directory, including any `core.hooksPath` override. Manually migrate unrecognized legacy inline-script bodies; preserve unrelated user/team hooks. Do not assume a wheel upgrade cleaned every workspace. +5. Request feature graph updates explicitly with `cmind script update_graphs.py update-rpg --json` or `/cmind.update_rpg`, allowing for normal provider approval requirements. + +Without `--ai`, `cmind update` refreshes integrations only and preserves the local selection byte-for-byte (or leaves it absent). It prefers an existing supported local provider for templates, then detects integration folders or prompts interactively for an integration only; none of these paths saves new consent. Malformed local state fails when read; the user must check local-store permissions/identity and explicitly choose whether to retry with `--ai`. `--ai` saves the selected provider only after Git hook reconciliation succeeds and leaves valid workspace hints unchanged. `--no-mcp` skips MCP registration, not hook reconciliation. This checklist is an explicit user maintenance action, not an automatic response to a rejected AI call. + +### Repository regression checks + +For maintainers, [../tests/run_security_tests.py](../tests/run_security_tests.py) includes expanded mocked policy, user-local state, Windows npm-layout/path, and hook-migration regressions. It uses an existing Python environment with dependencies already installed and redirects home/config/temp locations into a disposable repository directory. Its audit guard forbids real process launches, network access, and writes outside that directory. No dependency installation or machine-setting changes are needed. The guard prevents accidental test side effects, not hostile Python code. + +The explicit `--integration` mode instead runs inert local subprocess tests: installed CLI hooks, Git commits, Windows process-image checks, and trusted encoder fixtures. It permits absolute external executables while retaining the parent-process network/write guard and isolated home/workspaces. Child processes are not Python-audit sandboxes; these tests never invoke real AI providers or attack payloads. CI runs this mode against the installed wheel as well as the default no-process suite. -`cmind update` auto-detects the existing assistant configuration when possible. +The security CI workflow is configured for disposable Linux and Windows runners: source and freshly installed wheel tests, module-origin/byte checks, and explicit `sh` + `ps` builds using the Bash and PowerShell packagers respectively. The release verifier requires exactly 22 ZIPs (11 providers × 2 script types), exactly one per provider/type pair, and checks that all 22 variants carry the reviewed Python scripts: every source `.py` file recursively under `scripts` except `__pycache__`, after provider substitution and BOM/CRLF normalization, with AST syntax validation. Missing, duplicate, unexpected, or stale Python scripts and duplicate ZIP members fail verification. Stable and pre-release publishing depend on this CI and also verify their own freshly built publishing ZIPs immediately after packaging, before upload. This artifact coverage does not make `ps` a supported CLI script type. The local runner does not install packages or build releases; `--installed` is for CI after a fresh wheel installation. Neither this runner nor that workflow installs or executes real provider npm releases; mocked fixtures and artifact checks are not live compatibility tests or evidence of a completed CI run. ## Troubleshooting +### Configuration, authentication, access, or approval blocks + +Stop the workflow and surface the exact reported error; preserve any existing artifacts and diagnostic report paths. Do not treat these blocks as checkpoints or automatically retry, resume, initialize/update the workspace, rewrite configuration/local selection, set trust or environment overrides, or grant permissions. Ask the user to resolve the issue through the normal provider controls and explicitly authorize another attempt. A repository recommendation and a request to run a pipeline do not authorize those recovery changes. + ### AI assistant CLI not found Run: @@ -234,12 +283,7 @@ Run: cmind check ``` -Install and authenticate the missing assistant CLI, or rerun init with the assistant you want: - -```bash -cmind init my-project --ai claude -cmind init my-project --ai copilot -``` +Install and authenticate the intended CLI outside the workspace, with an absolute `PATH` directory. Availability detection is not execution-policy validation. On Windows, wrappers alone are insufficient: review the [direct-executable/npm adapter requirements](#executable-and-permission-policy), rather than adding paths or flags to configuration. A tracked recommendation alone also does not authorize execution; make an explicit local or trusted process selection. ### MCP tools say `rpg_unavailable` @@ -251,23 +295,17 @@ The MCP server is configured, but `.cmind/data/rpg.json` has not been created ye ### Incremental update failed -Check: - -```bash -tail -n 200 .cmind/logs/update_rpg.log -``` - -Then run: +Preserve the exact foreground error and any reported diagnostic artifact; use `cmind version` to locate the actual Logs directory. For configuration, authentication, access, or approval blocks, follow the stop rule above. After the user resolves the blocker and requests a retry: ```text /cmind.update_rpg ``` -If the graph is corrupted or too stale, run `/cmind.encode` for a full rebuild. +If the graph is corrupted or too stale, keep the existing graph and reports and ask the user whether to run `/cmind.encode` for a full rebuild. Do not delete artifacts automatically. ### Template download hits rate limits or private repo access errors -As of v0.1.4 `cmind init` and `cmind update` no longer fetch templates +`cmind init` and `cmind update` do not fetch templates from GitHub releases — templates are bundled inside the installed `cmind-cli` wheel, so this class of error should no longer occur during provisioning. To pick up newer templates, upgrade the CLI itself: @@ -276,5 +314,6 @@ provisioning. To pick up newer templates, upgrade the CLI itself: uv tool upgrade cmind-cli ``` -`cmind update` does this automatically by default; pass `--no-upgrade` -to opt out. +`cmind update` attempts a best-effort self-upgrade by default; pass +`--no-upgrade` to opt out. Existing workspaces still need the reconciliation +described in the [upgrade checklist](#updating-an-existing-codermind-project). diff --git a/CoderMind/docs/project-structure.md b/CoderMind/docs/project-structure.md index c99e7e6..fb88525 100644 --- a/CoderMind/docs/project-structure.md +++ b/CoderMind/docs/project-structure.md @@ -4,25 +4,30 @@ CoderMind installs alongside your project code: the directory you run `cmind init` in, also called the workspace root, **is** the project repository root. There is no separate `repo/` subdirectory. This means: -- `cmind init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and CoderMind's in-workspace configuration files (`.cmind/config.toml`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). -- `cmind init --here` inside an existing git repository adds CoderMind on top of the existing code without moving the repository. +- `cmind init my-project --ai claude` creates `my-project/` for your source code (`src/`, `tests/`, `docs/`) alongside CoderMind's in-workspace configuration files (`.cmind/config.toml`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). Init installs integrations, not your application code. +- `cmind init --here --ai claude` inside an existing git repository adds CoderMind on top of the existing code without moving the repository; use `--ai copilot` for Copilot. - A single `.git` repository tracks user-owned code and any CoderMind files the user chooses to commit. **Runtime data, logs, and the inner-git snapshot repo all live outside the workspace** under `~/.cmind/workspaces//`, so generated artefacts don't pollute your repo or accidentally get committed. Only a small set of user-facing files (`.cmind/config.toml`, `.cmind/reports/*.html`) stay inside the workspace. +- The tracked workspace marker contains only a provider recommendation, not executable authority. **User-local execution selection** lives separately under `~/.cmind/execution//selection.json`; it is neither repository configuration nor RPG metadata. ## After `cmind init` -Running `cmind init` downloads a template and creates a structure like this: +Running `cmind init` installs templates from the installed CLI's packaged bundle. Init requires an explicit `--ai` choice or an interactive selection; non-TTY init requires `--ai`. The layout below combines both supported integrations for reference; only the selected integration is installed. Source directories and reports appear as you add code or run pipeline stages. ```text my-project/ +├── src/ # Your application code (not generated by init) +├── tests/ # Your tests ├── docs/ # Optional requirement docs for /cmind.feature_spec │ ├── project_charter.md # Auto-detected when no description is provided │ └── ... ├── .claude/ # Claude Code configuration when --ai claude │ ├── commands/ # /cmind.* command definitions +│ │ ├── cmind.feature_construct.md │ │ ├── cmind.feature_spec.md │ │ ├── cmind.feature_build.md │ │ ├── cmind.feature_refactor.md │ │ ├── cmind.feature_edit.md +│ │ ├── cmind.plan.md │ │ ├── cmind.build_skeleton.md │ │ ├── cmind.build_data_flow.md │ │ ├── cmind.design_base_classes.md @@ -32,42 +37,58 @@ my-project/ │ │ ├── cmind.rpg_edit.md │ │ ├── cmind.encode.md │ │ └── cmind.update_rpg.md -│ └── settings.json # Permissions and MCP auto-approval +│ └── settings.json # SessionStart status + read-only rpg-tools allow rule +├── .mcp.json # Claude MCP registration (unless --no-mcp) ├── .github/ # Copilot configuration when --ai copilot │ ├── agents/ # cmind.* agent definitions │ └── prompts/ # companion prompts ├── .vscode/ # Copilot/VS Code configuration when applicable │ ├── mcp.json # MCP server registration -│ └── tasks.json # Optional workspace tasks -└── .cmind/ -│ ├── config.toml # Workspace AI / config (committed). See docs/configuration.md -│ ├── .source # Provisioning channel marker: "bundle" or "legacy" +│ └── tasks.json # folderOpen status task; no AI update +├── .gitignore # Ignores generated integrations and obsolete runtime copies +├── .cmind/ +│ ├── config.toml # Tracked discovery marker + provider hint; no execution consent │ └── reports/ # User-facing HTML reports (rpg.html, review HTML, ...) -└── .git/ # Your existing git repo - └── hooks/ # Installed by `cmind init` - ├── post-commit # Single line: `cmind hook post-commit` - └── post-merge # Single line: `cmind hook post-merge` +└── .git/ # Project Git metadata, not the CoderMind snapshot repo + └── hooks/ # CoderMind Git hooks OFF by default + ├── post-commit # Only with --git-hooks: foreground deterministic sync + └── post-merge # Only with --git-hooks: foreground deterministic sync ``` -### Out-of-workspace runtime store +The Git layout shown is for a normal checkout; `.git` may instead be a worktree/submodule pointer, and `core.hooksPath` may select another hooks directory. Init/update remove recognized CoderMind Git hook blocks by default, preserving unrelated content. Repeat `--git-hooks` on each invocation to retain sync-only hooks. They never launch AI or background workers; use `/cmind.update_rpg` explicitly for an AI-driven update. -Starting from the global-install layout, all runtime state lives under your home directory, keyed by a path-derived **slug** (the workspace's absolute path, lowercased, with non-alphanumeric runs collapsed to `-`): +### Out-of-workspace runtime and execution stores + +The RPG store is keyed by a path-derived **slug** (the workspace's absolute path, lowercased, with non-alphanumeric runs collapsed to `-`). Execution selection uses a separate full hash: ```text -~/.cmind/workspaces// -├── .git/ # Inner-git snapshot repo (per-stage auto-commits) -├── .gitignore # Excludes logs/copilot/ only — other logs are tracked for debug -├── .meta.toml # Back-pointer to the workspace path + metadata -├── data/ # Runtime artifacts (rpg.json, dep_graph.json, ...) -└── logs/ # Per-stage logs (tracked by inner-git; LLM session traces under logs/copilot/ are excluded) +~/ +├── .cmind/ +│ ├── execution// +│ │ └── selection.json # User-local provider selection + canonical workspace identity +│ └── workspaces// +│ ├── .meta.toml # Workspace back-pointer, provisioning channel, versions/timestamps +│ ├── .git/ # Private snapshot repo, when initialized +│ ├── .gitignore # Excludes logs/copilot/; other logs may be snapshotted +│ ├── data/ # RPG and pipeline artifacts +│ │ └── trajectory/ # Execution traces +│ └── logs/ # Per-stage logs, hooks.log, provider session traces +└── .copilot/ + └── mcp-config.json # Copilot CLI registration unless skipped ``` +`selection.json` records exactly `schema_version`, `workspace`, and `ai_provider`. Its directory uses the full SHA-256 of the canonical workspace identity, not the RPG slug. A trusted explicit constructor choice or trusted process environment can supply execution authority instead. Repository hints, `.meta.toml`, detected agent folders, and copied runtime state cannot authorize AI calls. Do not migrate consent by copying or editing records: a clone, move, or new user requires an explicit selection. See [configuration.md](configuration.md#user-local-execution-selection). + +The provisioning channel now lives in home-side `.meta.toml`, not an in-repository `.cmind/.source`. The snapshot repository lives in the RPG home store, not `/.cmind/.git`. `--no-cmind-git` skips snapshot initialization for this init, or backfilling a missing snapshot repo during update; it does not delete or disable an existing snapshot repo. This is separate from project `--no-git` and Git sync hooks. Copilot's home-side MCP registration can be skipped with `--no-copilot-cli-mcp`; `--no-mcp` skips MCP registration altogether, without removing existing permissions or registrations. + Reports (`rpg.html`, review HTML, …) stay **inside** the workspace at `/.cmind/reports/` because they are small, user-facing artefacts that benefit from sitting next to the code (and may be committed). `` is normally the slug itself (e.g. `home-hys-projects-myrepo`); paths whose slug exceeds 200 characters are truncated and given a 6-char base36 SHA-256 suffix so the directory name fits comfortably under POSIX `NAME_MAX` (255). Same shape as Claude Code's `~/.claude/projects/`. Moving or renaming the workspace yields a different id, so each clone has independent state. Run `cmind version` from inside the workspace to see the resolved paths (the **Data**, **Logs**, and **Inner git** lines). For backward compatibility, workspaces created before 0.1.4 (which used a 12-hex-char SHA-256 hash directory) continue to resolve correctly. > Pipeline scripts (formerly materialised into `.cmind/scripts/`) now live inside the installed `cmind-cli` wheel under `cmind_cli/core_pack/scripts/` and are invoked via the global [`cmind script `](cli-reference.md) command. They are no longer copied into each workspace, so `cmind init` produces a much smaller footprint and a single source of truth per CLI install. +Old ignored `.cmind/scripts/` copies are obsolete, not the source used by `cmind script`; do not run or refresh them as a migration step. Generated agent command folders are also ignored by default and can be materialized through an explicitly requested init/update. The tracked provider hint is intentionally retained. Bundled template provisioning itself needs no download, but optional encoding and update's default self-upgrade can use the network; this is not a whole-workflow offline guarantee. + The agent configuration directory varies by the selected AI assistant and release package. For the verified CLI path, `--ai claude` installs `.claude/commands/`, while `--ai copilot` installs `.github/agents/`, `.github/prompts/`, and `.vscode/mcp.json`. Command definitions are installed into the AI-agent-specific folder. Normal users should not need to inspect `~/.cmind/workspaces//data/` directly—run `cmind version` from the workspace to see all relevant paths. @@ -77,11 +98,14 @@ Command definitions are installed into the AI-agent-specific folder. Normal user | Artefact | Location | |---|---| | Your source code | `/` | -| Workspace AI config | `/.cmind/config.toml` | +| Tracked workspace marker / provider hint (not authority) | `/.cmind/config.toml` | +| User-local execution selection | `~/.cmind/execution//selection.json` | +| RPG / provisioning metadata (not authority) | `~/.cmind/workspaces//.meta.toml` | | User-facing HTML reports (`rpg.html`, …) | `/.cmind/reports/` | | Agent command definitions | `/.claude/` or `/.github/` | -| MCP / VS Code config | `/.vscode/` | -| Git hooks (`post-commit`, `post-merge`) | `/.git/hooks/` | +| Workspace MCP / status config | `/.mcp.json`, `.claude/settings.json`, or `.vscode/`, depending on agent | +| Copilot CLI MCP registration | `~/.copilot/mcp-config.json` | +| Opt-in Git sync hooks (`post-commit`, `post-merge`) | Active Git hooks directory, normally `/.git/hooks/`; honors `core.hooksPath` | | Generated data (`rpg.json`, `dep_graph.json`, …) | `~/.cmind/workspaces//data/` | | Per-stage logs | `~/.cmind/workspaces//logs/` | | Inner-git snapshot repo | `~/.cmind/workspaces//.git/` | @@ -116,7 +140,7 @@ As you run `/cmind.*` commands, `~/.cmind/workspaces//data/` is pr ## `rpg.json` — The Repository Planning Graph -`rpg.json` is the central graph artifact used by the forward pipeline, reverse encoder, MCP tools, incremental update hooks, and `/cmind.rpg_edit`. +`rpg.json` is the central graph artifact used by the forward pipeline, reverse encoder, MCP tools, opt-in deterministic sync hooks, explicit AI updates, and `/cmind.rpg_edit`. It can be created in either direction: @@ -129,7 +153,7 @@ Later commands enrich or maintain the same file: 2. **`design_base_classes`** — adds base-class relationship edges. 3. **`design_interfaces`** — adds fine-grained dependency edges such as inheritance, invocation, and references. 4. **`code_gen`** — updates implementation status as code is generated. -5. **`update_rpg`** — incrementally updates the RPG after commits when the hook is skipped or needs to be run manually. +5. **`update_rpg`** — explicitly runs an AI-driven incremental update of the current working tree against `HEAD~1`; requires an existing RPG and available history, independently of Git hooks. 6. **`rpg_edit`** — applies targeted feature graph edits together with code and dependency graph changes. ## `dep_graph.json` — Code Dependency Graph @@ -139,7 +163,7 @@ Later commands enrich or maintain the same file: Typical producers and updaters: - `/cmind.encode` creates the initial dependency graph when encoding an existing codebase. -- The post-commit hook and `/cmind.update_rpg` refresh it after code changes. +- Opt-in Git hooks provide deterministic sync after commits/merges; `/cmind.update_rpg` explicitly refreshes the graphs with AI after code changes. - `/cmind.rpg_edit` refreshes it after applying targeted code edits. ## Runtime Logs and Reports diff --git a/CoderMind/pyproject.toml b/CoderMind/pyproject.toml index f73a08c..711a211 100644 --- a/CoderMind/pyproject.toml +++ b/CoderMind/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cmind-cli" -version = "0.1.9" +version = "0.1.12" description = "CoderMind CLI - A tool to generate feature trees for repository planning and code generation." requires-python = ">=3.12" dependencies = [ diff --git a/CoderMind/scripts/code_gen/post_verify.py b/CoderMind/scripts/code_gen/post_verify.py index c5de7ee..c28fe0b 100644 --- a/CoderMind/scripts/code_gen/post_verify.py +++ b/CoderMind/scripts/code_gen/post_verify.py @@ -31,6 +31,7 @@ ) from common.git_utils import GitRunner from common.task_batch import PlannedTask +from common.trusted_tools import resolve_git from code_gen.test_runner import ( ensure_deps_installed, find_related_test_files, @@ -86,7 +87,7 @@ def _git_diff_test_files(prefix: str = "tests/") -> list: try: main_branch = GitRunner(str(repo_path)).main_branch diff = subprocess.run( - ["git", "diff", f"{main_branch}..HEAD", "--name-only"], + [resolve_git(repo_path), "diff", f"{main_branch}..HEAD", "--name-only"], cwd=repo_path, capture_output=True, text=True, timeout=10, ) return [ diff --git a/CoderMind/scripts/common/ai_cli_policy.py b/CoderMind/scripts/common/ai_cli_policy.py new file mode 100644 index 0000000..6b1d5cc --- /dev/null +++ b/CoderMind/scripts/common/ai_cli_policy.py @@ -0,0 +1,244 @@ +"""Closed AI provider policy shared by the CLI and packaged pipeline scripts. + +This module uses only the standard library and has no import-time I/O. Repository +configuration can recommend a provider, never authorize execution. Only an +explicit caller, process environment or user-local workspace selection can do so. +""" + +from __future__ import annotations + +import os +import hashlib +import json +from pathlib import Path, PurePath, PureWindowsPath +import tempfile +import tomllib +from typing import Mapping + +_IS_WINDOWS = os.name == "nt" + + +class AICommandPolicyError(ValueError): + """An AI command could not be authorized by the closed provider policy.""" + + +PROVIDER_ARGV = { + "copilot": ("copilot",), + "claude": ("claude",), + "gemini": ("gemini", "-p"), + "qwen": ("qwen", "-p"), + "cursor-agent": ("agent", "-p"), + "auggie": ("augment", "-p"), + "codex": ("codex", "exec"), + "codebuddy": ("codebuddy", "-p"), + "qoder": ("qodercli", "-p"), + "opencode": ("opencode", "run"), + "amp": ("amp", "--execute"), +} +_LEGACY_PROVIDERS = {" ".join(argv): name for name, argv in PROVIDER_ARGV.items()} + + +def validate_provider(value: object) -> str: + if not isinstance(value, str) or value not in PROVIDER_ARGV: + raise AICommandPolicyError( + "Invalid AI provider. Select a built-in ai_provider; executable paths " + "and additional arguments are not supported." + ) + return value + + +def provider_from_command(value: object) -> str: + """Accept only exact historical built-in commands, not shell-like syntax.""" + if not isinstance(value, str) or value not in _LEGACY_PROVIDERS: + raise AICommandPolicyError( + "Unsupported ai_cli_cmd. Replace it with a built-in ai_provider. " + "Custom commands, paths and additional arguments are not supported." + ) + return _LEGACY_PROVIDERS[value] + + +def read_workspace_provider(workspace: Path) -> str: + """Validate and return a recommendation only, including legacy spellings. + + Never use this return value as execution authority or copy it to local state + without an explicit user choice. Keeping the file preserves workspace lookup. + """ + config = workspace / ".cmind" / "config.toml" + try: + with config.open("rb") as stream: + data = tomllib.load(stream) + except FileNotFoundError as exc: + # A dangling symlink is an invalid configuration, not an absent one. + if config.is_symlink(): + raise AICommandPolicyError("Cannot read workspace AI configuration.") from exc + return "" + except (OSError, ValueError) as exc: + raise AICommandPolicyError("Cannot read workspace AI configuration.") from exc + table = data.get("cmind", {}) + if not isinstance(table, dict): + raise AICommandPolicyError("The cmind configuration must be a TOML table.") + keys = {"recommended_provider", "ai_provider", "ai_cli_cmd"} & table.keys() + if len(keys) > 1: + raise AICommandPolicyError("Use only one workspace provider recommendation.") + if "recommended_provider" in table: + return validate_provider(table["recommended_provider"]) + if "ai_provider" in table: + return validate_provider(table["ai_provider"]) + if "ai_cli_cmd" in table: + return provider_from_command(table["ai_cli_cmd"]) + return "" + + +def _comparison_path(path: PurePath) -> PurePath: + """Normalize Windows extended prefixes for identity/containment, not I/O.""" + if isinstance(path, PureWindowsPath): + spelling = str(path) + if spelling[:8].lower() == "\\\\?\\unc\\": + return PureWindowsPath("\\\\" + spelling[8:]) + if spelling.startswith("\\\\?\\"): + return PureWindowsPath(spelling[4:]) + return path + + +def _is_within(path: PurePath, root: PurePath) -> bool: + return _comparison_path(path).is_relative_to(_comparison_path(root)) + + +def _workspace_identity(workspace: Path) -> str: + return os.path.normcase(str(_comparison_path(workspace.resolve()))) + + +def local_selection_path(workspace: Path) -> Path: + """User-owned state, separate from copyable RPG metadata and repository files. + + A full path hash plus an identity field prevents slug collisions or copied + records from authorizing another workspace. No repository path controls this + location. Reject redirected state directories and any store in the workspace. + """ + home = Path.home().resolve() + digest = hashlib.sha256(_workspace_identity(workspace).encode("utf-8")).hexdigest() + path = home / ".cmind" / "execution" / digest / "selection.json" + for part in (home / ".cmind", home / ".cmind" / "execution", path.parent, path): + resolved = part.resolve() + if (part.is_symlink() or _comparison_path(resolved) != _comparison_path(part) + or _is_within(resolved, workspace.resolve())): + raise AICommandPolicyError("User-local AI selection must not be redirected or stored in the workspace.") + return path + + +def read_local_provider(workspace: Path) -> str: + path = local_selection_path(workspace) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return "" + except (OSError, ValueError) as exc: + raise AICommandPolicyError("Cannot read user-local AI selection; explicitly select the provider again.") from exc + if (not isinstance(data, dict) + or set(data) != {"schema_version", "workspace", "ai_provider"} + or type(data["schema_version"]) is not int or data["schema_version"] != 1 + or data["workspace"] != _workspace_identity(workspace)): + raise AICommandPolicyError("User-local AI selection has an invalid schema or workspace identity.") + return validate_provider(data["ai_provider"]) + + +def write_local_provider(workspace: Path, provider: str) -> None: + """Persist ONLY an explicit user choice, called after successful init/update. + + This is product behavior, never run against the developer's real profile by + tests. Atomic publication preserves the prior selection on write failure. + """ + validate_provider(provider) + path = local_selection_path(workspace) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + local_selection_path(workspace) # Recheck after directory creation. + data = {"schema_version": 1, "workspace": _workspace_identity(workspace), "ai_provider": provider} + temp_path = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, + prefix=".selection-", suffix=".tmp", delete=False) as stream: + temp_path = Path(stream.name) + json.dump(data, stream, ensure_ascii=True, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + local_selection_path(workspace) + os.replace(temp_path, path) + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +def resolve_provider( + workspace: Path, + *, + tool: str | None = None, + environ: Mapping[str, str] | None = None, + baked: str = "", +) -> str: + """Resolve explicit caller > process/CI environment > user-local selection. + + Repository values and legacy release defaults are validated recommendations, + never executable authority. In particular, absence of local state does not + fall back to the cloned repository or to a release-baked provider. + """ + read_workspace_provider(workspace) + if baked: + provider_from_command(baked) # Legacy default is NOT an authorization. + env = os.environ if environ is None else environ + if tool is not None: + return provider_from_command(tool) + if "CMIND_AI_PROVIDER" in env and "CMIND_AI_CLI_CMD" in env: + raise AICommandPolicyError("Set only one AI provider environment override.") + if "CMIND_AI_PROVIDER" in env: + return validate_provider(env["CMIND_AI_PROVIDER"]) + if "CMIND_AI_CLI_CMD" in env: + return provider_from_command(env["CMIND_AI_CLI_CMD"]) + return read_local_provider(workspace) + + +def build_argv(provider: str, workspace: Path, *, search_path: str | None = None) -> list[str]: + """Resolve a built-in command without shell interpretation of prompt text. + + Direct native executables take priority. On Windows, known external npm + installations can be launched through their fixed native/Node entry points + without running the .cmd/.ps1 shim. Arbitrary wrappers remain unsupported. + The user's installed tools and absolute PATH directories remain trusted. + """ + base = PROVIDER_ARGV[validate_provider(provider)] + workspace = workspace.resolve() + cwd = Path.cwd().resolve() + path = os.environ.get("PATH", "") if search_path is None else search_path + filename = base[0] + ".exe" if _IS_WINDOWS else base[0] + directories = [] + for entry in path.split(os.pathsep): + directory = Path(entry) + if not entry or not directory.is_absolute(): + continue + try: + canonical_dir = directory.resolve(strict=True) + if any(_is_within(directory, root) or _is_within(canonical_dir, root) + for root in (workspace, cwd)): + continue + directories.append(canonical_dir) + candidate = (canonical_dir / filename).resolve(strict=True) + if any(_is_within(candidate, root) for root in (workspace, cwd)): + continue + if _IS_WINDOWS and candidate.suffix.lower() != ".exe": + continue + if candidate.is_file() and os.access(candidate, os.X_OK): + return [str(candidate), *base[1:]] + except (OSError, RuntimeError): + continue + if _IS_WINDOWS: + from .windows_ai_cli import resolve_windows_argv + + adapted = resolve_windows_argv(provider, directories, (workspace, cwd)) + if adapted is not None: + return [*adapted, *base[1:]] + raise AICommandPolicyError( + "No trusted AI executable found. Install the selected provider outside " + "the workspace and use an absolute PATH entry. Windows supports native " + "executables and recognized Claude/Copilot npm entries (JS entries require " + "an external node.exe). Arbitrary script wrappers are not executed." + ) diff --git a/CoderMind/scripts/common/generated_artifacts.py b/CoderMind/scripts/common/generated_artifacts.py index 10c6d3c..ef24844 100644 --- a/CoderMind/scripts/common/generated_artifacts.py +++ b/CoderMind/scripts/common/generated_artifacts.py @@ -6,6 +6,8 @@ from pathlib import Path, PurePosixPath import subprocess +from .trusted_tools import resolve_git + _EXCLUDE_MARKER_BEGIN = "# BEGIN CoderMind generated artifact hygiene" _EXCLUDE_MARKER_END = "# END CoderMind generated artifact hygiene" @@ -127,7 +129,7 @@ def _run_git( ) -> subprocess.CompletedProcess[str] | None: try: return subprocess.run( - ["git", *args], + [resolve_git(repo_path), *args], cwd=repo_path, capture_output=True, text=True, diff --git a/CoderMind/scripts/common/git_utils.py b/CoderMind/scripts/common/git_utils.py index 3c72866..497f658 100644 --- a/CoderMind/scripts/common/git_utils.py +++ b/CoderMind/scripts/common/git_utils.py @@ -16,6 +16,8 @@ from typing import Optional, Tuple, List, Dict from dataclasses import dataclass +from .trusted_tools import resolve_git + _INVALID_REF_CHARS = re.compile(r"[^A-Za-z0-9._-]+") @@ -101,7 +103,7 @@ def run_git( check: bool = False, capture_output: bool = True ) -> GitResult: - """Run a git subcommand (automatically prepends 'git'). + """Run a git subcommand using a trusted absolute executable path. Args: args: Git subcommand arguments (e.g., ["add", "-A"]) @@ -111,8 +113,8 @@ def run_git( Returns: GitResult with success status and output """ - cmd = ["git"] + args try: + cmd = [resolve_git(self.repo_path)] + args result = subprocess.run( cmd, cwd=self.repo_path, @@ -495,7 +497,7 @@ def _run_git_readonly( """ try: result = subprocess.run( - ["git", *args], + [resolve_git(cwd), *args], cwd=str(cwd), capture_output=True, text=True, @@ -514,7 +516,7 @@ def read_head(repo_dir: str | Path) -> Optional[dict]: Returns ``None`` if: * ``repo_dir`` does not exist - * ``git`` is not on PATH + * no trusted external Git executable is available * ``repo_dir`` is not a git working tree * The repository has no commits yet (unborn HEAD) diff --git a/CoderMind/scripts/common/llm_client.py b/CoderMind/scripts/common/llm_client.py index 1e469e6..fc69af5 100644 --- a/CoderMind/scripts/common/llm_client.py +++ b/CoderMind/scripts/common/llm_client.py @@ -15,7 +15,6 @@ import signal as _signal import subprocess import time -import tomllib from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -24,7 +23,11 @@ from common.llm_types import Memory from common.session_manager import create_session_manager from . import paths as _paths -from .paths import REPO_DIR as _REPO_DIR, WORKSPACE_ROOT as _WORKSPACE_ROOT +from .ai_cli_policy import ( + AICommandPolicyError, PROVIDER_ARGV, build_argv, provider_from_command, + resolve_provider, +) +from .paths import WORKSPACE_ROOT as _WORKSPACE_ROOT _IS_WINDOWS = _platform.system() == "Windows" @@ -70,11 +73,10 @@ def _kill_process_tree(proc: subprocess.Popen) -> None: # ---------------------------------------------------------------------------- # # Resolution priority: -# P1. LLMClient(tool="...") constructor argument -# P2. CMIND_AI_CLI_CMD env var -# P3. /.cmind/config.toml [cmind].ai_cli_cmd -# P4. _BAKED_IN_VALUE (release-zip builds substitute it at packaging time; -# bundle builds leave the placeholder unchanged) +# P1. LLMClient(tool="...") exact legacy built-in command +# P2. CMIND_AI_PROVIDER, or legacy CMIND_AI_CLI_CMD (mutually exclusive) +# P3. User-local provider selection bound to the canonical workspace path +# Repository recommendations and legacy release defaults never authorize a call. # # An unresolved value is reported lazily by LLMClient.generate, not here, # so importing the module and constructing an LLMClient without calling @@ -87,56 +89,27 @@ def _kill_process_tree(proc: subprocess.Popen) -> None: _BAKED_IN_VALUE = "" -def _load_ai_cli_cmd() -> str: - """Resolve the AI CLI command string via the P1-P4 priority chain. - - P1 is handled by :class:`LLMClient.__init__` (constructor argument). - This function implements P2-P4 and returns ``""`` if none of them - yield a usable value — callers decide how to react. - - The workspace root is *re-resolved at every invocation* via - :func:`paths._find_workspace_root`, not via the import-frozen - :data:`paths.WORKSPACE_ROOT` constant. This matters for long-lived - processes that may serve more than one workspace (e.g. a future - global MCP server). - """ - # P2: env var (highest non-P1 priority — useful in tests and one-off - # overrides without editing the workspace config). - env_val = _os.environ.get("CMIND_AI_CLI_CMD", "").strip() - if env_val: - return env_val - - # P3: workspace config.toml. - try: - workspace = _paths._find_workspace_root() - cfg_path = workspace / ".cmind" / "config.toml" - if cfg_path.exists(): - with open(cfg_path, "rb") as f: - data = tomllib.load(f) - cfg_val = (data.get("cmind") or {}).get("ai_cli_cmd", "") - if isinstance(cfg_val, str): - cfg_val = cfg_val.strip() - if cfg_val: - return cfg_val - except Exception: - # paths resolution, missing tomllib, or malformed TOML must not - # crash LLMClient construction. - pass - - # P4: legacy baked-in value (release-zip-substituted at build time). - if _BAKED_IN_VALUE and _BAKED_IN_VALUE != _PLACEHOLDER_LITERAL: - return _BAKED_IN_VALUE - - return "" +def _load_ai_cli_cmd(workspace: Optional[Path] = None) -> str: + """Return a canonical built-in command, never raw configuration text.""" + provider = resolve_provider( + workspace if workspace is not None else _paths._find_workspace_root(), + baked="" if _BAKED_IN_VALUE == _PLACEHOLDER_LITERAL else _BAKED_IN_VALUE, + ) + return " ".join(PROVIDER_ARGV[provider]) if provider else "" # Resolved once at import for backward-compat with callers that referenced # the module-level constant directly. New code should call ``_load_ai_cli_cmd()`` # or use ``LLMClient.tool`` (already populated through the same chain). -AI_CLI_CMD = _load_ai_cli_cmd() +try: + AI_CLI_CMD = _load_ai_cli_cmd() +except AICommandPolicyError: + # Compatibility snapshot only. Read-only modules may still import us in an + # invalid workspace; actual clients re-resolve and fail closed below. + AI_CLI_CMD = "" -# Mapping from the first token of AI_CLI_CMD to the canonical agent name +# Mapping from validated built-in executables to session-manager names _CLI_TO_AGENT = { "copilot": "copilot", "claude": "claude", @@ -155,6 +128,10 @@ def _load_ai_cli_cmd() -> str: def detect_agent_type(cmd: Optional[str] = None) -> str: """Detect which AI coding agent is being used. + Identification only, not execution authorization. Keep Windows path/quote + recognition for diagnostic callers; LLMClient validates against the closed + provider policy before selecting a session manager or launching a process. + Args: cmd: Optional explicit CLI command string. When omitted we resolve dynamically via :func:`_load_ai_cli_cmd` so this @@ -262,31 +239,35 @@ def __init__( """Initialize LLM Client. Args: - tool: CLI tool command (default: "llm") + tool: Exact legacy built-in command; None resolves the provider policy trajectory: Trajectory instance for recording LLM calls step_id: Current step ID in the trajectory logger: Logger instance """ - # P1 (explicit arg) wins; otherwise P2-P4 chain via _load_ai_cli_cmd. + # P1 (explicit arg) wins; otherwise environment/user-local selection. # The empty-string case is tolerated here so unit tests / utilities # that construct an LLMClient without intending to invoke the LLM # keep working. The actual error is raised in :meth:`generate` if # the tool is still empty when a call is attempted. - self.tool = tool if tool is not None else _load_ai_cli_cmd() + self._workspace = _paths._find_workspace_root().resolve() + self._explicit_tool = tool + if tool is not None: + resolve_provider(self._workspace, tool=tool) + self.tool = tool if tool is not None else _load_ai_cli_cmd(self._workspace) self.trajectory = trajectory self.step_id = step_id self.logger = logger or logging.getLogger(__name__) # Session manager — driven by detect_agent_type(self.tool) so the # right subclass is chosen even when self.tool came from the - # workspace config (not the import-time AI_CLI_CMD constant). - # project_dir must match the subprocess cwd (workspace root == REPO_DIR) + # local selection (not the import-time AI_CLI_CMD constant). + # project_dir must match the subprocess cwd (the resolved workspace root) # so that Claude CLI's session file path # (~/.claude/projects//) can be correctly located by # the session manager. self._session_manager = create_session_manager( agent_type=detect_agent_type(self.tool), - project_dir=_REPO_DIR, + project_dir=self._workspace, trace_filename_builder=self._build_trace_filename, logger=self.logger, ) @@ -372,17 +353,29 @@ def generate( Raises: RuntimeError: If LLM call fails after all retries """ + # Hooks are deterministic only. This also protects old dispatcher + # workers that reach a newly installed LLM client. + if _os.environ.get("CMIND_HOOK"): + raise AICommandPolicyError("AI execution from Git hooks is disabled.") + if self._explicit_tool is None and _load_ai_cli_cmd(self._workspace) != self.tool: + raise AICommandPolicyError("AI configuration changed; create a new client.") + # Lazy validation: the constructor tolerates an empty/placeholder # ``self.tool`` so that tests and tools can build an LLMClient # without triggering an LLM invocation, but the moment we are # actually asked to call out, the configuration must be valid. if not self.tool or self.tool == _PLACEHOLDER_LITERAL: raise RuntimeError( - "AI CLI command not configured. Run " - "`cmind init --ai ` in this workspace, or set the " - "CMIND_AI_CLI_CMD environment variable." + "AI CLI command not configured for this user and workspace. Run " + "`cmind init --ai ` or `cmind update --ai `, or set " + "CMIND_AI_PROVIDER in a trusted CI/process environment. " + "Repository recommendations do not authorize execution." ) + # Validate outside the retry/trace scope: a rejected configuration must + # not launch anything, prepare session files, or get retried. + base_argv = build_argv(provider_from_command(self.tool), self._workspace) + # Create call record self._call_counter += 1 call_record = LLMCallRecord( @@ -420,7 +413,7 @@ def generate( # and resets stdin for re-reading the prompt. if attempt > 0: trace_ctx.refresh_for_retry() - cmd = shlex.split(self.tool) + trace_ctx.extra_args + cmd = [*base_argv, *trace_ctx.extra_args] # Sub-agent runs in the project repo directory. # POSIX: start_new_session=True puts the child in its own @@ -438,7 +431,8 @@ def generate( encoding="utf-8", errors="replace", env=trace_ctx.env, - cwd=_REPO_DIR, + cwd=self._workspace, + shell=False, ) if _IS_WINDOWS: popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP diff --git a/CoderMind/scripts/common/rpg_io.py b/CoderMind/scripts/common/rpg_io.py index 4221704..454fe0b 100644 --- a/CoderMind/scripts/common/rpg_io.py +++ b/CoderMind/scripts/common/rpg_io.py @@ -53,6 +53,8 @@ from pathlib import Path from typing import Any, Optional +from .trusted_tools import resolve_git + logger = logging.getLogger(__name__) @@ -212,8 +214,9 @@ def _try_restore_from_inner_git( relpath = _git_relpath_for(path) if relpath is None: return None - from shutil import which - if which("git") is None: + try: + git = resolve_git(git_dir) + except OSError: return None # Force English git messages (consistent with _inner_git.py). @@ -231,7 +234,7 @@ def _try_restore_from_inner_git( # working when a script ever renames data files in the future. try: log = subprocess.run( - ["git", "-C", str(git_dir), "log", "--follow", + [git, "-C", str(git_dir), "log", "--follow", "--format=%H", "--", relpath], capture_output=True, text=True, env=env, timeout=10, ) @@ -244,7 +247,7 @@ def _try_restore_from_inner_git( for commit in commits: try: show = subprocess.run( - ["git", "-C", str(git_dir), "show", f"{commit}:{relpath}"], + [git, "-C", str(git_dir), "show", f"{commit}:{relpath}"], capture_output=True, text=True, env=env, timeout=10, ) except (subprocess.SubprocessError, OSError): diff --git a/CoderMind/scripts/common/session_manager.py b/CoderMind/scripts/common/session_manager.py index df8fd5a..8312d67 100644 --- a/CoderMind/scripts/common/session_manager.py +++ b/CoderMind/scripts/common/session_manager.py @@ -308,13 +308,10 @@ def before(self, ctx: TraceContext, prompt: str) -> None: file and exposed as ``ctx.stdin``. """ self._session_id = str(uuid.uuid4()) - # --dangerously-skip-permissions: required for autonomous sub-agent - # execution in the TDD workflow. The sub-agent must read/write files - # and run pytest without interactive permission prompts. This flag - # should ONLY be used in controlled, single-tenant environments. + # Keep the provider's normal permission checks. Repository configuration + # must never implicitly authorize autonomous tool execution. ctx.extra_args.extend([ "-p", "--session-id", self._session_id, - "--dangerously-skip-permissions", ]) ctx.env.pop("CLAUDECODE", None) @@ -428,14 +425,14 @@ class CopilotSessionManager(SessionManager): """Session manager for the GitHub Copilot CLI. Injects Copilot-specific CLI arguments (``--log-dir``, - ``--log-level``, ``--allow-all``) into ``extra_args`` and appends + ``--log-level``) into ``extra_args`` and appends the prompt as the final argument. """ def before(self, ctx: TraceContext, prompt: str) -> None: """Inject Copilot-specific CLI flags and append prompt. - Adds ``--log-dir``, ``--log-level``, ``--allow-all`` and the + Adds ``--log-dir``, ``--log-level`` and the prompt text itself to ``extra_args``. """ log_dir = COPILOT_LOGS_DIR @@ -443,7 +440,6 @@ def before(self, ctx: TraceContext, prompt: str) -> None: ctx.extra_args.extend([ "--log-dir", str(log_dir), "--log-level", "all", - "--allow-all", "-p", prompt, ]) diff --git a/CoderMind/scripts/common/trusted_tools.py b/CoderMind/scripts/common/trusted_tools.py new file mode 100644 index 0000000..5135baf --- /dev/null +++ b/CoderMind/scripts/common/trusted_tools.py @@ -0,0 +1,81 @@ +"""Resolve product tools without searching repository-controlled executables. + +The user's external installations and absolute PATH directories are trusted. +Repository/cwd directories, their aliases, and Windows shell wrappers are not. +Standard library only: importing this module does not inspect the filesystem. +""" + +from __future__ import annotations + +import os +from pathlib import Path, PurePath, PureWindowsPath + +_IS_WINDOWS = os.name == "nt" +_TOOLS = frozenset({"git", "uv", "pipx"}) + + +def _comparison_path(path: PurePath) -> PurePath: + if isinstance(path, PureWindowsPath): + value = str(path) + if value[:8].lower() == "\\\\?\\unc\\": + return PureWindowsPath("\\\\" + value[8:]) + if value.startswith("\\\\?\\"): + return PureWindowsPath(value[4:]) + return path + + +def _within(path: Path, root: Path) -> bool: + return _comparison_path(path).is_relative_to(_comparison_path(root)) + + +def _boundary(path: Path) -> Path: + """Include the enclosing checkout when called from one of its subfolders.""" + path = path.resolve() + for parent in (path, *path.parents): + if ((parent / ".git").exists() or (parent / ".git").is_symlink() + or (parent / ".cmind" / "config.toml").exists() + or (parent / ".cmind" / "config.toml").is_symlink()): + return parent + return path + + +def resolve_tool(name: str, workspace: Path | str, *, search_path: str | None = None) -> str: + """Return an absolute external executable, or fail without launching anything. + + Windows probes .exe only, never PATHEXT/.cmd/.bat/.ps1 or cwd fallback. + Both lexical and canonical paths are checked to reject redirected entries. + No cache: a different workspace or PATH must not reuse prior authorization. + """ + if name not in _TOOLS: + raise ValueError("Unsupported product tool") + try: + roots = (_boundary(Path(workspace)), _boundary(Path.cwd())) + except (OSError, RuntimeError, ValueError) as exc: + raise FileNotFoundError("Cannot establish trusted tool search boundaries") from exc + filename = name + ".exe" if _IS_WINDOWS else name + path = os.environ.get("PATH", "") if search_path is None else search_path + for entry in path.split(os.pathsep): + directory = Path(entry) + if not entry or not directory.is_absolute(): + continue + try: + canonical_dir = directory.resolve(strict=True) + if any(_within(p, root) for p in (directory, canonical_dir) for root in roots): + continue + candidate = (canonical_dir / filename).resolve(strict=True) + if any(_within(candidate, root) for root in roots): + continue + if _IS_WINDOWS and candidate.suffix.lower() != ".exe": + continue + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + except (OSError, RuntimeError, ValueError): + continue + raise FileNotFoundError( + f"No trusted {name} executable found outside the workspace/current checkout. " + "Install it externally and use an absolute PATH directory." + ) + + +def resolve_git(workspace: Path | str) -> str: + return resolve_tool("git", workspace) diff --git a/CoderMind/scripts/common/windows_ai_cli.py b/CoderMind/scripts/common/windows_ai_cli.py new file mode 100644 index 0000000..ab52914 --- /dev/null +++ b/CoderMind/scripts/common/windows_ai_cli.py @@ -0,0 +1,199 @@ +"""Shell-free adapters for trusted Windows npm installs of Claude and Copilot. + +Layout evidence supplied from the public, versioned unpkg package.json endpoints: + @anthropic-ai/claude-code@2.1.278: claude -> bin/claude.exe + @anthropic-ai/claude-code@2.0.0: claude -> cli.js + @github/copilot@1.0.87: copilot -> npm-loader.js + @github/copilot@0.0.369: copilot -> index.js + +These versions document layouts, not version pins or publisher authentication. +The external installation directories and their contents remain user-trusted; +local metadata checks cannot detect a tampered installation. Copilot's loader +and its dependencies are part of that trust boundary. In particular, this +module does not infer a native Copilot sibling dependency from its filename. +Claude links escaping its package root are also deliberately unsupported. + +Only the standard library is used, with no import-time I/O or policy imports. +No wrappers are read, parsed, or executed, and no process is launched here. +""" + +from __future__ import annotations + +import json +from pathlib import Path, PurePath, PureWindowsPath + + +_PACKAGES = { + "claude": ("@anthropic-ai/claude-code", frozenset({"bin/claude.exe", "cli.js"})), + "copilot": ("@github/copilot", frozenset({"npm-loader.js", "index.js"})), +} +_MAX_MANIFEST_BYTES = 64 * 1024 + + +def _comparison_path(path: PurePath) -> PurePath: + # Windows resolve() can add an extended-length prefix to only one side of + # a containment check. Normalize the spelling for comparison, not for I/O. + if isinstance(path, PureWindowsPath): + spelling = str(path) + if spelling[:8].lower() == "\\\\?\\unc\\": + return PureWindowsPath("\\\\" + spelling[8:]) + if spelling.startswith("\\\\?\\"): + return PureWindowsPath(spelling[4:]) + return path + + +def _is_within(path: PurePath, parent: PurePath) -> bool: + return _comparison_path(path).is_relative_to(_comparison_path(parent)) + + +def _is_excluded(path: Path, roots: tuple[Path, ...]) -> bool: + return any(_is_within(path, root) for root in roots) + + +def _safe_path( + path: Path, roots: tuple[Path, ...], *, parent: Path | None = None +) -> Path | None: + """Check both the supplied spelling and the strict canonical destination.""" + try: + if not path.is_absolute() or _is_excluded(path, roots): + return None + if parent is not None and not _is_within(path, parent): + return None + canonical = path.resolve(strict=True) + if _is_excluded(canonical, roots): + return None + if parent is not None and not _is_within(canonical, parent): + return None + return canonical + except (OSError, RuntimeError, ValueError): + # Missing/inaccessible paths, symlink loops, and invalid path spellings. + return None + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate JSON key") + result[key] = value + return result + + +def _reject_constant(value: str) -> None: + raise ValueError("Non-JSON numeric constant") + + +def _entry_name( + manifest: Path, provider: str, package_name: str, allowed: frozenset[str] +) -> str | None: + try: + if not manifest.is_file(): + return None + with manifest.open("rb") as stream: + raw = stream.read(_MAX_MANIFEST_BYTES + 1) + if len(raw) > _MAX_MANIFEST_BYTES: + return None + metadata = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + if not isinstance(metadata, dict) or metadata.get("name") != package_name: + return None + bins = metadata.get("bin") + if not isinstance(bins, dict) or set(bins) != {provider}: + return None + entry = bins[provider] + if not isinstance(entry, str) or entry not in allowed: + return None + return entry + except (OSError, ValueError, RecursionError): + return None + + +def _node_executable(directories: list[Path], roots: tuple[Path, ...]) -> Path | None: + for directory in directories: + node = _safe_path(directory / "node.exe", roots) + try: + # A node.exe symlink must not turn the returned command into a shell + # wrapper. The native executable's contents are still user-trusted. + if node is not None and node.suffix.lower() == ".exe" and node.is_file(): + return node + except OSError: + continue + return None + + +def resolve_windows_argv( + provider: str, directories: list[Path], excluded_roots: tuple[Path, ...] +) -> list[str] | None: + """Return only an absolute executable and, for Node, its fixed JS entry. + + The caller first searches ALL trusted directories for direct native provider + executables (for every provider). This fallback supports only the two npm + packages above. The caller appends PROVIDER_ARGV[provider][1:] afterwards. + + ``directories`` are ordered, absolute, canonical, user-trusted search paths; + ``excluded_roots`` are absolute workspace/current-directory roots. Defensive + checks discard unsafe directories and also check canonical root aliases. + npm packages live immediately under each directory's node_modules. Node is + searched only as node.exe in the same supplied directories, including the + npm prefix itself, never via PATH, cwd, a parent directory, npm, or npx. + + Native Claude needs no Node installation. JavaScript entries require a safe + node.exe; missing/invalid candidates are skipped and ultimately return None. + This is filesystem/metadata validation, not executable or publisher + verification, and does not secure an attacker-writable trusted installation. + The function is OS-independent for testing; the caller selects it on Windows. + """ + if not isinstance(provider, str) or provider not in _PACKAGES: + return None + + roots: list[Path] = [] + try: + for root in excluded_roots: + if not root.is_absolute(): + return None + roots.extend((root, root.resolve())) + except (OSError, RuntimeError, ValueError): + # Do not proceed if the exclusion boundary cannot be established. + return None + excluded = tuple(roots) + + safe_directories: list[Path] = [] + for directory in directories: + canonical = _safe_path(directory, excluded) + try: + if canonical is not None and canonical.is_dir(): + safe_directories.append(canonical) + except OSError: + continue + + package_name, allowed = _PACKAGES[provider] + for directory in safe_directories: + package = _safe_path(directory / "node_modules" / package_name, excluded) + if package is None: + continue + try: + if not package.is_dir(): + continue + manifest = _safe_path(package / "package.json", excluded, parent=package) + if manifest is None: + continue + name = _entry_name(manifest, provider, package_name, allowed) + if name is None: + continue + entry = _safe_path(package / name, excluded, parent=package) + if entry is None or not entry.is_file(): + continue + # Preserve the executable/script kind even through an internal link. + if entry.suffix.lower() != Path(name).suffix: + continue + if name == "bin/claude.exe": + return [str(entry)] + node = _node_executable(safe_directories, excluded) + if node is not None: + return [str(node), str(entry)] + except (OSError, RuntimeError, ValueError): + continue + return None diff --git a/CoderMind/scripts/rpg_edit/apply.py b/CoderMind/scripts/rpg_edit/apply.py index 2421576..269cd6e 100644 --- a/CoderMind/scripts/rpg_edit/apply.py +++ b/CoderMind/scripts/rpg_edit/apply.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(SCRIPTS_DIR)) from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, REPO_DIR, RPG_EDIT_PLAN_FILE # noqa: E402 +from common.trusted_tools import resolve_git # noqa: E402 def _backup(rpg_path: Path, dep_graph_path: Path, ts: str) -> Dict[str, str]: @@ -165,7 +166,7 @@ def main(): repo_dir = args.repo_dir or REPO_DIR try: proc = subprocess.run( - ["git", "-C", str(repo_dir), "branch", "-D", args.rollback_branch], + [resolve_git(repo_dir), "-C", str(repo_dir), "branch", "-D", args.rollback_branch], capture_output=True, text=True, timeout=10, ) branch_result = { diff --git a/CoderMind/scripts/rpg_edit/code.py b/CoderMind/scripts/rpg_edit/code.py index c070be7..1dfb7d9 100644 --- a/CoderMind/scripts/rpg_edit/code.py +++ b/CoderMind/scripts/rpg_edit/code.py @@ -48,6 +48,7 @@ cmd_for, ) from common.logging_setup import setup_file_logging # noqa: E402 +from common.trusted_tools import resolve_git # noqa: E402 logger = logging.getLogger(__name__) @@ -399,8 +400,9 @@ def _commit_changes( """Stage all changes and create a single commit. Returns commit SHA.""" # Check if there is anything to commit try: + git = resolve_git(repo_path) st = subprocess.run( - ["git", "-C", str(repo_path), "status", "--porcelain"], + [git, "-C", str(repo_path), "status", "--porcelain"], capture_output=True, text=True, timeout=10, ) except Exception as exc: @@ -415,15 +417,15 @@ def _commit_changes( msg += f" [{status}]" try: subprocess.run( - ["git", "-C", str(repo_path), "add", "-A"], + [git, "-C", str(repo_path), "add", "-A"], check=True, capture_output=True, timeout=30, ) subprocess.run( - ["git", "-C", str(repo_path), "commit", "-m", msg], + [git, "-C", str(repo_path), "commit", "-m", msg], check=True, capture_output=True, timeout=30, ) sha = subprocess.run( - ["git", "-C", str(repo_path), "rev-parse", "HEAD"], + [git, "-C", str(repo_path), "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10, ) return sha.stdout.strip() or None diff --git a/CoderMind/scripts/run_batch.py b/CoderMind/scripts/run_batch.py index d2ed385..972a09c 100644 --- a/CoderMind/scripts/run_batch.py +++ b/CoderMind/scripts/run_batch.py @@ -1054,11 +1054,7 @@ def main() -> int: if args.prune_failed: repo_path = REPO_DIR git = GitRunner(str(repo_path)) - import subprocess as _sp - raw = _sp.run( - ["git", "branch", "--format=%(refname:short)"], - cwd=repo_path, capture_output=True, text=True, - ) + raw = git.run_git(["branch", "--format=%(refname:short)"]) all_branches = [b.strip() for b in raw.stdout.splitlines() if b.strip()] current = git.get_current_branch() deleted = [] diff --git a/CoderMind/scripts/update_graphs.py b/CoderMind/scripts/update_graphs.py index b9964b3..c3b3f86 100644 --- a/CoderMind/scripts/update_graphs.py +++ b/CoderMind/scripts/update_graphs.py @@ -6,8 +6,7 @@ enrich Enrich feature graph from actual code (align paths + fill missing) sync Full sync: dep + enrich + mappings update-rpg Full RPG update (dep_graph + feature graph via LLM) against - the previous git commit. Designed to run in the background - from post-commit hooks. + the previous git commit. Run explicitly outside Git hooks. mapping Rebuild dep_graph + dep↔rpg mappings (legacy) feature Load existing dep_graph, rebuild mappings (legacy) full AST scan + mappings + edges (legacy, use 'sync' instead) @@ -34,19 +33,17 @@ from common.paths import REPO_RPG_FILE, DEP_GRAPH_FILE, RPG_HTML_FILE, HOOK_CALLS_LOG # noqa: E402 from common.rpg_io import atomic_write_rpg, safe_load_rpg # noqa: E402 +from common.trusted_tools import resolve_git # noqa: E402 # Shared message used by every subcommand that requires an existing -# ``rpg.json`` (sync, update-rpg, ...). Surfaces in two places: -# * ``.cmind/logs/update_rpg.log`` for the asynchronous post-commit -# phase — where it's the user's only diagnostic. -# * stdout / JSON output for direct CLI invocations. +# ``rpg.json`` (sync, update-rpg, ...). Surfaces in stdout / JSON output +# for explicit CLI invocations outside Git hooks. # Keep the message single-line so it survives JSON serialisation cleanly # and stays easy to grep. _RPG_MISSING_MSG = ( "rpg.json not found at {rpg_path}. Run /cmind.encode in your AI agent " - "to generate it; the post-commit hook will resume keeping it in sync " - "on the next commit." + "to generate it, then run /cmind.update_rpg explicitly to keep it in sync." ) @@ -80,18 +77,17 @@ def _log_hook_call(hook_type: str, result: dict) -> None: def _refresh_rpg_html(rpg_path: Path) -> dict: - """Regenerate ``rpg.html`` next to ``rpg.json`` after a hook update. + """Regenerate ``rpg.html`` after an explicit graph update. The encoder's ``run_encode.py`` already produces ``rpg.html`` via - :mod:`rpg_visualize` during the initial full encode, but the - pre-/post-commit hooks only re-write ``rpg.json``. Without this - refresh, the interactive visualisation drifts behind the graph - until the next full encode. + :mod:`rpg_visualize` during the initial full encode. Explicit graph + updates also refresh the interactive visualisation so it does not + drift behind the graph until the next full encode. Best-effort: any failure (missing rpg.json, parse error, write - permission) is swallowed so a slow / broken viz never blocks a - commit. The returned dict surfaces ``viz_path`` on success or - ``viz_error`` on failure so callers can include it in the hook + permission) is swallowed so a broken viz does not fail a graph + update. The returned dict surfaces ``viz_path`` on success or + ``viz_error`` on failure so callers can include it in the command output for debugging. """ result: dict = {} @@ -125,9 +121,8 @@ def update_dep_only(code_dir: str, workspace_root: str, dep_graph_path: Path, rebuilt dep_graph, and writes ``rpg.json`` back out. When ``rpg_path`` is ``None`` (or the file is missing) we fall back to the legacy standalone ``dep_graph.json`` write so that environments - which haven't run the encoder yet still get a useful artefact — - this is the path the very-first pre-commit hook hits on a fresh - workspace before any RPG exists. + which haven't run the encoder yet still get a useful artefact from + an explicit update. ``dep_graph_path`` is preserved as a parameter for CLI back-compat but is now used only in the legacy fallback path. @@ -164,8 +159,7 @@ def update_dep_only(code_dir: str, workspace_root: str, dep_graph_path: Path, } # Legacy fallback: write standalone dep_graph.json for environments - # without an rpg.json yet (rare in practice — the pre-commit hook - # exits early on workspaces that never ran the encoder). + # without an rpg.json yet. raw = dg.to_dict() raw["code_dir"] = code_dir_rel from datetime import datetime, timezone @@ -336,11 +330,11 @@ def cmd_sync( ) -> dict: """Subcommand: sync — commit-aware incremental refresh. - Pre-commit hook path: pass ``staged_only=True`` so only ``git add``'d - files contribute to the diff (working-tree-but-not-staged changes - are out of scope for the imminent commit). + Run explicitly outside Git hooks. Pass ``staged_only=True`` so only + ``git add``'d files contribute to the diff; unstaged changes are + out of scope for that update. - Manual CLI path: omit ``staged_only`` (default ``False``) and the + Otherwise, omit ``staged_only`` (default ``False``) and the full working tree is considered dirty. Falls back to full rebuild whenever: @@ -357,12 +351,9 @@ def cmd_sync( t0 = time.time() - # Fail-soft when the workspace hasn't run the encoder yet. Without - # this guard, ``RPGService.load`` raises ``FileNotFoundError`` which - # the post-commit hook's ``|| true`` would swallow silently — making - # the failure invisible during debugging. Emit a structured error - # instead so the hook log shows exactly what's wrong and how to fix - # it. + # Fail-soft when the workspace hasn't run the encoder yet. Emit a + # structured error instead of RPGService.load's FileNotFoundError + # so the command output explains what's wrong and how to fix it. if not rpg_path.is_file(): return { "mode": "sync", @@ -395,9 +386,9 @@ def cmd_sync( # Keep ``rpg.html`` aligned with the freshly-saved ``rpg.json``. # The encoder produces both files during the initial full encode, - # but earlier hook revisions only refreshed the JSON — leaving the - # visualisation silently stale. Best-effort: ``_refresh_rpg_html`` - # swallows its own errors so a broken viz can never block a commit. + # and explicit updates refresh both too. Best-effort: + # ``_refresh_rpg_html`` swallows its own errors so a broken viz + # does not fail the graph update. viz_result = _refresh_rpg_html(rpg_path) sync_out = { @@ -442,10 +433,9 @@ def cmd_update_rpg( runs ``run_update_rpg`` (LLM-driven feature tree diff + dep_graph rebuild), and cleans up the worktree. - Designed for post-commit background invocation via ``setsid``:: + Run explicitly outside Git hooks:: - setsid env -u GIT_INDEX_FILE -u GIT_DIR sh -c \ - "cd ; cmind script update_graphs.py update-rpg --json >> log 2>&1" & + cmind script update_graphs.py update-rpg --json Requires: - rpg.json exists (encode has been run) @@ -466,8 +456,9 @@ def cmd_update_rpg( # Check git has enough history try: + git = resolve_git(workspace_root) prev_ref = subprocess.check_output( - ["git", "rev-parse", "--verify", "HEAD~1"], + [git, "rev-parse", "--verify", "HEAD~1"], cwd=workspace_root, stderr=subprocess.DEVNULL, ).decode().strip() @@ -479,7 +470,7 @@ def cmd_update_rpg( # Prune orphaned worktrees from previous runs that were killed. subprocess.call( - ["git", "worktree", "prune"], + [git, "worktree", "prune"], cwd=workspace_root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -489,7 +480,7 @@ def cmd_update_rpg( worktree_dir = tempfile.mkdtemp(prefix="cmind_prev_") try: wt_proc = subprocess.run( - ["git", "worktree", "add", worktree_dir, prev_ref, "--detach", "-q"], + [git, "worktree", "add", worktree_dir, prev_ref, "--detach", "-q"], cwd=workspace_root, capture_output=True, text=True, @@ -531,7 +522,7 @@ def cmd_update_rpg( # Clean up worktree try: subprocess.call( - ["git", "worktree", "remove", worktree_dir, "--force"], + [git, "worktree", "remove", worktree_dir, "--force"], cwd=workspace_root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -731,9 +722,7 @@ def _branch_suffix(branch): f"{_branch_suffix(last_branch)}; " f"current HEAD is {cur_short}" f"{_branch_suffix(cur_branch)}{branch_note}. " - "Run /cmind.update_rpg " - "(or commit to trigger the pre-commit sync hook) to " - "refresh the graph." + "Run /cmind.update_rpg explicitly to refresh the graph." ) elif last_short and not cur_short: lines.append( @@ -832,7 +821,7 @@ def _add_common(p): "--staged-only", action="store_true", help=( - "Only consider ``git add``'d files (pre-commit hook scope). " + "Only consider ``git add``'d files in this explicit update. " "Without this flag, the entire working tree is considered." ), ) @@ -858,7 +847,7 @@ def _add_common(p): ) _add_common(p_status) - # update-rpg (full RPG update via LLM; background post-commit) + # update-rpg (explicit full RPG update via LLM, outside Git hooks) p_update_rpg = sub.add_parser( "update-rpg", help="Full RPG update (dep_graph + feature graph via LLM). " @@ -892,8 +881,8 @@ def _add_common(p): workspace_root = os.getcwd() - # For background hook processes (setsid) or any caller whose cwd is - # not the workspace root, infer the workspace. Earlier versions of + # For any caller whose cwd is not the workspace root, infer the + # workspace. Earlier versions of # this fallback walked up from ``args.rpg`` assuming a layout of # ``/.cmind/data/rpg.json``, which became wrong once the # default ``rpg.json`` moved into the home-side store @@ -928,7 +917,7 @@ def _add_common(p): if command == "dep": # ``rpg_path`` is preferred (embedded dep_graph); falls back to # writing a legacy standalone dep_graph when the workspace has no - # rpg.json yet (very first commit before /cmind.encode). + # rpg.json yet (before /cmind.encode). result = update_dep_only( code_dir, workspace_root, args.dep_graph, rpg_path=args.rpg, diff --git a/CoderMind/src/cmind_cli/__init__.py b/CoderMind/src/cmind_cli/__init__.py index 75cbcdc..6fb69b6 100644 --- a/CoderMind/src/cmind_cli/__init__.py +++ b/CoderMind/src/cmind_cli/__init__.py @@ -52,9 +52,14 @@ from datetime import datetime, timezone import platform import importlib.metadata +import importlib.util +from functools import lru_cache +from types import ModuleType import tomllib from . import _storage +from ._trusted_tools import cli_argv as _cli_argv, resolve_git as _resolve_git, resolve_tool as _resolve_tool +from ._trusted_tools import cli_shell_command as _cli_shell_command ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -118,7 +123,7 @@ def _get_repo_info() -> Tuple[str, str]: for remote_name in ("upstream", "origin"): try: result = subprocess.run( - ["git", "remote", "get-url", remote_name], + [_resolve_git(Path.cwd()), "remote", "get-url", remote_name], capture_output=True, text=True, timeout=5, @@ -326,14 +331,8 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # assets under ``cmind_cli/core_pack/`` so that ``cmind init`` works # offline. This block exposes: # -# _AI_TO_CLI_CMD — single source of truth for "selected AI" → -# "AI CLI command to invoke from scripts". -# Must stay in sync with the corresponding case -# statement in -# ``.github/workflows/scripts/cmind/create-release-packages.sh`` -# (the release-zip pipeline) and with -# ``scripts/common/llm_client.py:_CLI_TO_AGENT`` -# (the reverse mapping consumed by detect_agent_type()). +# _AI_TO_CLI_CMD — compatibility view of the shared closed provider +# policy; never accepts workspace command strings. # # _SOURCE_BUNDLE / _SOURCE_LEGACY — provisioning channel; persisted as # ``channel`` in ``~/.cmind/workspaces/ @@ -342,23 +341,54 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # user's original choice. Mirrors the # constants in :mod:`cmind_cli._storage`. +@lru_cache(maxsize=1) +def _ai_cli_policy() -> ModuleType: + """Load the shared stdlib-only policy from trusted installed assets. + + Do not import ``common`` through sys.path: the caller's workspace may + contain a package with that name. The editable fallback is likewise + anchored to this installation, never to the working directory. + """ + from . import _assets + + path = (_assets.scripts_dir() / "common" / "ai_cli_policy.py").resolve() + spec = importlib.util.spec_from_file_location("_cmind_cli_ai_cli_policy", path) + if spec is None or spec.loader is None: + raise RuntimeError("Cannot load the installed AI provider policy.") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + _AI_TO_CLI_CMD = { - # NOTE: values below are copied verbatim from - # .github/workflows/scripts/cmind/create-release-packages.sh lines ~142-169 - # to guarantee bundle mode and legacy-download mode behave identically. - "copilot": "copilot", - "claude": "claude", - "gemini": "gemini -p", - "qwen": "qwen -p", - "cursor-agent": "agent -p", - "auggie": "augment -p", - "codex": "codex exec", - "codebuddy": "codebuddy -p", - "qoder": "qodercli -p", - "opencode": "opencode run", - "amp": "amp --execute", + provider: " ".join(argv) + for provider, argv in _ai_cli_policy().PROVIDER_ARGV.items() } +_AI_POLICY_ERROR = ( + "Cannot use workspace AI recommendation. Check the TOML syntax and use only " + "one built-in recommended_provider, legacy ai_provider or exact legacy ai_cli_cmd. " + "Repository hints never authorize execution. Select a provider explicitly with " + "--ai for user-local storage, or in a trusted CI environment. " + "Custom commands, executable paths and extra arguments are not allowed." +) +_LOCAL_AI_SELECTION_ERROR = ( + "Cannot read or save user-local AI selection. Check ~/.cmind/execution " + "permissions and workspace identity, then retry cmind init/update with " + "--ai copilot or --ai claude. Repository recommendations cannot authorize execution." +) + + +def _preflight_workspace_policy(project_path: Path) -> None: + """Reject invalid config before provisioning, hook changes or self-upgrade.""" + policy = _ai_cli_policy() + try: + policy.read_workspace_provider(project_path) + except policy.AICommandPolicyError: + console.print(_AI_POLICY_ERROR, style="red", markup=False) + raise typer.Exit(1) from None + + # Re-exported (under the older names) to minimise churn at call sites; # the canonical strings now live in :mod:`cmind_cli._storage`. _SOURCE_BUNDLE = _storage.CHANNEL_BUNDLE @@ -413,33 +443,54 @@ def _write_source_marker(project_path: Path, source: str) -> None: def _write_workspace_config(project_path: Path, selected_ai: str) -> None: - """Materialise ``.cmind/config.toml`` with the selected AI's CLI command. + """Write a workspace marker with a non-authoritative provider recommendation. - Idempotent: if the file already exists and already contains - ``ai_cli_cmd``, leave it alone (the user may have customised it). - Only writes a fresh file when one is missing. + Validate existing configuration and preserve valid files byte-for-byte, + including exact legacy built-in commands. Invalid files are rejected, + not silently kept or overwritten. Only missing files are created; local + execution consent is stored separately after hooks succeed. """ cfg_path = project_path / _CONFIG_RELPATH - cli_cmd = _AI_TO_CLI_CMD.get(selected_ai, selected_ai) + policy = _ai_cli_policy() + policy.read_workspace_provider(project_path) if cfg_path.exists(): - # Don't clobber user edits. We could merge here, but plain - # workspaces don't need the complexity and a stale value is a - # supported configuration (env var override remains available). + with cfg_path.open("rb") as stream: + table = tomllib.load(stream).get("cmind", {}) + if "ai_provider" in table or "ai_cli_cmd" in table: + console.print( + "Warning: legacy workspace AI hint preserved; it is not execution authority. " + "Only an explicit provider choice can be saved to user-local storage.", + style="yellow", markup=False, + ) return + provider = policy.validate_provider(selected_ai) cfg_path.parent.mkdir(parents=True, exist_ok=True) cfg_path.write_text( "# CoderMind workspace configuration\n" - "# Managed by `cmind init` / `cmind update`. Safe to commit.\n" + "# Tracked workspace marker and provider recommendation only.\n" + "# Never execution authority: select a provider explicitly in user-local storage\n" + "# with cmind init/update --ai, or through a trusted CI environment.\n" + "# Git hooks never authorize automatic AI calls.\n" "# See: https://github.com/microsoft/RPG-ZeroRepo (CoderMind/docs/configuration.md)\n" "\n" "[cmind]\n" - f'ai_cli_cmd = "{cli_cmd}"\n', + f'recommended_provider = "{provider}"\n', encoding="utf-8", ) +def _save_local_ai_selection(project_path: Path, selected_ai: str) -> None: + """Persist explicit consent only after successful hook reconciliation.""" + policy = _ai_cli_policy() + try: + policy.write_local_provider(project_path, selected_ai) + except (policy.AICommandPolicyError, OSError): + raise RuntimeError(_LOCAL_AI_SELECTION_ERROR) from None + console.print(f"[cyan]User-local AI provider saved:[/cyan] {selected_ai}") + + def _detect_install_method() -> str: """Best-effort detection of how ``cmind-cli`` was installed. @@ -843,8 +894,8 @@ def _install_source() -> str: # descend into a directory ignored as a whole, so the ``!`` negation # below would have no effect with the directory form. .cmind/* -# but DO track the workspace AI config so collaborators see the same -# default — see docs/configuration.md +# DO track the workspace marker and provider recommendation only. +# Execution authority stays user-local or in trusted CI, never in this file. !.cmind/config.toml # Legacy runtime dir from pre-cmind (rpgkit) workspaces — kept so users @@ -1218,7 +1269,7 @@ def is_git_repo(path: Path = None) -> bool: try: # Use git command to check if inside a work tree subprocess.run( - ["git", "rev-parse", "--is-inside-work-tree"], + [_resolve_git(path), "rev-parse", "--is-inside-work-tree"], check=True, capture_output=True, cwd=path, @@ -1347,10 +1398,11 @@ def init_git_repo( os.chdir(project_path) if not quiet: console.print("[cyan]Initializing git repository...[/cyan]") - subprocess.run(["git", "init"], check=True, capture_output=True, text=True) - subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True) + git = _resolve_git(project_path) + subprocess.run([git, "init"], check=True, capture_output=True, text=True) + subprocess.run([git, "add", "."], check=True, capture_output=True, text=True) subprocess.run( - ["git", "commit", "-m", "Initial commit from CoderMind template"], + [git, "commit", "-m", "Initial commit from CoderMind template"], check=True, capture_output=True, text=True, @@ -1956,23 +2008,15 @@ def _run_initial_encode(project_path: Path) -> bool: get here and we don't want a flaky LLM call to make the whole command look like it failed. """ - encoder = project_path / ".cmind" / "scripts" / "rpg_encoder" / "run_encode.py" - if not encoder.is_file(): - # Scripts live inside the installed wheel under - # ``cmind_cli/core_pack/scripts/``. Resolve the encoder - # from there so the optional initial-encode kickoff works after - # ``cmind init`` — which no longer copies scripts into the - # workspace. - from . import _assets - candidate = _assets.scripts_dir() / "rpg_encoder" / "run_encode.py" - if candidate.is_file(): - encoder = candidate - else: - console.print( - f"[yellow]Encoder script not found at {candidate}; " - f"run [cyan]/cmind.encode[/] in your AI agent later.[/yellow]" - ) - return False + # Use the same installed-asset boundary as `cmind script`. Legacy scripts + # delivered in a cloned workspace are data, never an execution fallback. + encoder = _resolve_script_path("rpg_encoder/run_encode.py") + if encoder is None: + console.print( + "[yellow]Installed encoder script not found; repair the CoderMind " + "installation before running [cyan]/cmind.encode[/].[/yellow]" + ) + return False # Keep all generated artefacts (logs/data/inner-git) in the # per-workspace home dir under ~/.cmind/workspaces//. The @@ -2199,8 +2243,9 @@ def _stderr_reader() -> None: Panel( "[green]Encoder finished successfully.[/]\n\n" "The RPG graph is now available under your home-dir " - "workspace store ([cyan]rpg.json[/]). The post-commit hook will " - "keep it in sync on every commit; the MCP tools " + "workspace store ([cyan]rpg.json[/]). Opt-in Git hooks perform " + "deterministic sync only; AI updates require an explicit " + "[cyan]cmind script update_graphs.py update-rpg[/] invocation. The MCP tools " "([cyan]search_rpg[/], [cyan]explore_rpg[/], …) are now usable.", title="[bold green]Encode complete[/bold green]", border_style="green", @@ -2331,10 +2376,8 @@ def _install_claude_hooks(project_path: Path) -> None: if settings_path.exists(): shutil.copy2(settings_path, settings_dir / "settings.json.bak") - # The command is executed by Claude Code via ``sh -c``, so we inline - # the same PATH-fallback used by git hooks (see _HOOK_PATH_FALLBACK). - # Use ``;`` rather than ``&&`` so the cmind call always runs after - # the (possibly no-op) PATH adjustment. + # Claude executes via sh: quote the current installation's absolute entry, + # never resolve a bare cmind in the repository or inherited shell PATH. marker = "update_graphs.py" # used for idempotent dedupe across upgrades rpg_session_entry = { @@ -2343,8 +2386,7 @@ def _install_claude_hooks(project_path: Path) -> None: { "type": "command", "command": ( - f"{_HOOK_PATH_FALLBACK}; " - "cmind script update_graphs.py status 2>/dev/null" + f"{_cli_shell_command('script', 'update_graphs.py', 'status')} 2>/dev/null" " || echo '[CoderMind] RPG status unavailable'" ), "timeout": 10, @@ -2423,7 +2465,7 @@ def _read_core_hooks_path(project_path: Path) -> Optional[Path]: """ try: result = subprocess.run( - ["git", "config", "--get", "core.hooksPath"], + [_resolve_git(project_path), "config", "--get", "core.hooksPath"], cwd=project_path, capture_output=True, text=True, @@ -2501,12 +2543,53 @@ def _resolve_git_hooks_dir(project_path: Path) -> Optional[Path]: # Each entry describes a CoderMind-owned hook snippet shape that can be -# recognized without sentinels. The first element is a substring of the -# snippet's marker comment; the second is the total number of consecutive -# lines occupied by that snippet. These are removed before the sentinel -# block is written so users do not end up with duplicate CoderMind logic. +# recognized without sentinels. The marker must match a whole comment; +# the count bounds the known body, which is checked before removal so a +# shorter legacy snippet never consumes the next user-authored line. LegacyBlock = Tuple[str, int] +_LEGACY_GIT_HOOK_BLOCKS: Dict[str, Tuple[LegacyBlock, ...]] = { + "pre-commit": ( + ("# CoderMind: pre-commit dispatcher", 3), + ("# CoderMind: full RPG sync on commit", 2), + ("# CoderMind: incremental RPG sync on commit", 3), + ), + "post-commit": ( + ("# CoderMind: post-commit dispatcher", 3), + ("# CoderMind: advance meta.git after commit", 2), + ("# CoderMind: advance meta.git + background feature graph update", 5), + ), + "post-merge": ( + ("# CoderMind: post-merge dispatcher", 3), + ("# CoderMind: incremental RPG sync after merge / pull", 3), + ), +} + + +def _is_legacy_hook_sync(line: str) -> bool: + """Recognize the old cmind/Python sync command, not arbitrary shell lines.""" + try: + parts = shlex.split(line) + except ValueError: + return False + if parts[-2:] == ["||", "true"]: + parts = parts[:-2] + if parts[-1:] == ["2>/dev/null"]: + parts = parts[:-1] + if parts[:2] == ["cmind", "script"]: + args = parts[2:] + elif parts and re.fullmatch( + r"python(?:\d+(?:\.\d+)*)?(?:\.exe)?", + parts[0].replace("\\", "/").rsplit("/", 1)[-1], + ): + args = parts[1:] + else: + return False + return bool(args) and ( + args[0].replace("\\", "/").rsplit("/", 1)[-1] == "update_graphs.py" + and args[1:] in (["sync"], ["sync", "--staged-only"]) + ) + def _strip_hook_block( text: str, @@ -2526,57 +2609,77 @@ def _strip_hook_block( Range-based, so multi-line bodies of any shape are atomically removed in one shot. - 2. Strip each compatibility snippet described by - ``(marker_substring, line_count)``. The marker line plus - ``line_count - 1`` lines following it are dropped. Multiple - shapes are removed in a single pass so the order of entries in - ``legacy_blocks`` doesn't matter. + 2. Strip recognized compatibility bodies following exact legacy + markers, bounded by their historical line counts. Incomplete or + unrecognized bodies are preserved, not guessed at. Lines outside both passes are preserved verbatim so user-authored - hook content (and shebangs) survive untouched. + hook content (including line endings and shebangs) survives untouched. + An unmatched begin sentinel is preserved rather than deleting the tail. """ begin_sent = f"# CMIND-BEGIN {block_name}" end_sent = f"# CMIND-END {block_name}" - lines = text.splitlines() + lines = text.splitlines(keepends=True) # Pass 1: strip sentinel block (matching pair). after_sentinels: list[str] = [] - inside = False - for line in lines: - stripped = line.strip() - if not inside and stripped == begin_sent: - inside = True - continue - if inside and stripped == end_sent: - inside = False - continue - if inside: - continue - after_sentinels.append(line) - - # Pass 2: strip compatibility snippets by (marker, line_count). - if not legacy_blocks: - return "\n".join(after_sentinels) - + i = 0 + while i < len(lines): + if lines[i].strip() == begin_sent: + end = next( + (j for j in range(i + 1, len(lines)) if lines[j].strip() == end_sent), + None, + ) + if end is not None: + i = end + 1 + continue + # With no matching end, ownership of the remaining body is + # unknown. Do not run legacy cleanup on that ambiguous tail. + return "".join(after_sentinels) + "".join(lines[i:]) + after_sentinels.append(lines[i]) + i += 1 + + # Pass 2: recognize complete legacy bodies, never blindly skip lines. out: list[str] = [] - skip = 0 - for line in after_sentinels: - if skip > 0: - skip -= 1 + i = 0 + while i < len(after_sentinels): + count = next( + (count for marker, count in legacy_blocks + if after_sentinels[i].strip() == marker), + 0, + ) + body = [line.strip() for line in after_sentinels[i + 1:i + count]] + consumed = 0 + if count == 5: + # Historical sync + setsid worker (one shell line), guarded by + # an if/fi lock check. Remove only the complete known shape. + if ( + len(body) == 4 + and _is_legacy_hook_sync(body[0]) + and body[1].startswith("if [ ! -f ") + and body[1].endswith("]; then") + and body[2].startswith("setsid ") + and re.search(r"update_graphs\.py['\"]?\s+update-rpg(?:\s|$)", body[2]) + and body[3] == "fi" + ): + consumed = 5 + elif body: + offset = 1 if body[0] == _HOOK_PATH_FALLBACK else 0 + if len(body) > offset and ( + _is_legacy_hook_sync(body[offset]) + or body[offset] == f"cmind hook {block_name} 2>/dev/null || true" + ): + consumed = 2 + offset + if consumed: + i += consumed continue - matched = False - for marker, count in legacy_blocks: - if marker in line: - skip = max(count - 1, 0) - matched = True - break - if not matched: - out.append(line) - return "\n".join(out) + out.append(after_sentinels[i]) + i += 1 + return "".join(out) # --------------------------------------------------------------------------- -# PATH fallback for hook bodies +# Historical PATH fallback, retained ONLY for recognizing legacy hook bodies # --------------------------------------------------------------------------- # # Hooks invoke ``cmind`` (the globally-installed CLI) rather than a @@ -2585,11 +2688,8 @@ def _strip_hook_block( # the process environment may not include the user's shell PATH, so # ``cmind`` is unresolvable and the hook silently fails. # -# This snippet is prepended to every hook body. When ``cmind`` is -# already on PATH (terminal invocations) the test short-circuits and -# the ``export`` is skipped — zero overhead. When it isn't, we -# prepend ``$HOME/.local/bin`` which is ``uv tool install``'s default -# bin directory. +# New hooks pin the interpreter and installed bootstrap; they do not emit this +# snippet. Keep the exact old text so reconciliation can remove known bodies. _HOOK_PATH_FALLBACK = ( 'command -v cmind >/dev/null 2>&1 || ' 'export PATH="$HOME/.local/bin:$PATH"' @@ -2651,47 +2751,44 @@ def _install_hook_snippet( def _uninstall_git_pre_commit_hook(project_path: Path) -> bool: - """Remove any CoderMind-owned ``pre-commit`` block. + """Remove retired CoderMind-owned ``pre-commit`` blocks only.""" + return _uninstall_git_hook(project_path, "pre-commit") - The active git hook contract uses ``post-commit`` and ``post-merge``. - CoderMind-owned pre-commit blocks are stripped here; user-authored hook - content (and other tools' blocks such as husky / pre-commit / - lefthook) is preserved untouched. - Returns ``True`` when the workspace had a hooks dir to clean, - ``False`` only when no git checkout was found at all. +def _uninstall_git_hook(project_path: Path, hook_name: str) -> bool: + """Remove only cmind-owned blocks, preserving user bytes and file mode. + + Returns False when no Git checkout is found. Missing or unrelated + hooks are no-ops; in particular, never delete a user's empty hook. + Read/decode/removal errors propagate; never decode malformed bytes lossily. """ hooks_dir = _resolve_git_hooks_dir(project_path) if hooks_dir is None: return False - hook_path = hooks_dir / "pre-commit" - if not hook_path.is_file(): + hook_path = hooks_dir / hook_name + try: + existing = hook_path.read_bytes().decode("utf-8") + except FileNotFoundError: return True - existing = hook_path.read_text(encoding="utf-8") - legacy = ( - ("# CoderMind: pre-commit dispatcher", 3), - ("# CoderMind: full RPG sync on commit", 2), - ("# CoderMind: incremental RPG sync on commit", 3), + cleaned = _strip_hook_block( + existing, hook_name, _LEGACY_GIT_HOOK_BLOCKS[hook_name] ) - cleaned = _strip_hook_block(existing, "pre-commit", legacy).rstrip("\n") + if cleaned == existing: + return True # If nothing user-authored remains, delete the hook file so git # falls back to its default no-hook behaviour. if not cleaned.strip() or cleaned.strip() == "#!/bin/sh": - try: - hook_path.unlink() - except OSError: - pass + hook_path.unlink() else: - hook_path.write_text(cleaned + "\n", encoding="utf-8") - hook_path.chmod(0o755) + hook_path.write_bytes(cleaned.encode("utf-8")) return True def _install_git_post_merge_hook(project_path: Path) -> bool: - """Install the RPG sync command into ``post-merge``. + """Install opt-in deterministic sync into ``post-merge``, never AI work. Fires after ``git pull`` / ``git merge`` so the dep_graph stays aligned with code the user just received from a teammate. Cannot @@ -2710,41 +2807,28 @@ def _install_git_post_merge_hook(project_path: Path) -> bool: marker = "# CoderMind: post-merge dispatcher" body = ( f"{marker}\n" - f"{_HOOK_PATH_FALLBACK}\n" - f"cmind hook post-merge 2>/dev/null || true" + f"{_cli_shell_command('hook', 'post-merge')} 2>/dev/null || true" ) return _install_hook_snippet( hooks_dir, "post-merge", "post-merge", body, - legacy_blocks=( - ("# CoderMind: incremental RPG sync after merge / pull", 3), - ), + legacy_blocks=_LEGACY_GIT_HOOK_BLOCKS["post-merge"], ) def _install_git_post_commit_hook(project_path: Path) -> bool: - """Install the ``post-commit`` dispatcher stub. + """Install the opt-in, deterministic ``post-commit`` sync dispatcher. The on-disk hook is a short shell snippet that delegates to - ``cmind hook post-commit``. All orchestration lives in the - :func:`hook` Python command: - - * **Foreground sync**: ``update_graphs.py sync`` advances - ``meta.git`` to the new HEAD. Output is teed into - ``~/.cmind/workspaces//logs/hooks.log``. - - * **Background update**: ``update_graphs.py update-rpg`` is - detached via ``subprocess.Popen(start_new_session=True)``. A - mkdir-based directory lock at - ``~/.cmind/workspaces//logs/.update_rpg.lock`` serialises - overlapping commits; locks older than 60 minutes are treated as - orphaned and removed. The worker's stdout/stderr land in - ``~/.cmind/workspaces//logs/update_rpg.log``. + ``cmind hook post-commit``. The Python dispatcher only runs + ``update_graphs.py sync``, logging to the home-side ``hooks.log``. + No hook launches an AI call or a background worker. AI-driven updates + require an explicit user invocation of + ``cmind script update_graphs.py update-rpg`` instead. - Both steps are best-effort: every failure path is swallowed inside - :func:`hook` so a hook misbehaviour never blocks ``git commit``. + Sync is best-effort so a hook failure never blocks ``git commit``. CoderMind-owned multi-line shell bodies are stripped on upgrade by the ``legacy_blocks`` compatibility patterns below. @@ -2756,20 +2840,14 @@ def _install_git_post_commit_hook(project_path: Path) -> bool: marker = "# CoderMind: post-commit dispatcher" body = ( f"{marker}\n" - f"{_HOOK_PATH_FALLBACK}\n" - f"cmind hook post-commit 2>/dev/null || true" + f"{_cli_shell_command('hook', 'post-commit')} 2>/dev/null || true" ) return _install_hook_snippet( hooks_dir, "post-commit", "post-commit", body, - legacy_blocks=( - # Two-line sync-only snippet. - ("# CoderMind: advance meta.git after commit", 2), - # Five-line sync + setsid background-update snippet. - ("# CoderMind: advance meta.git + background feature graph update", 5), - ), + legacy_blocks=_LEGACY_GIT_HOOK_BLOCKS["post-commit"], ) @@ -2800,15 +2878,13 @@ def _install_copilot_hooks(project_path: Path) -> None: # Backup is best-effort; never block installation on it. pass + command = _cli_argv("script", "update_graphs.py", "status") rpg_status_task = { "label": "CoderMind: load status", - "type": "shell", - # Invoke the globally-installed CLI rather than a workspace - # script copy (which no longer exists). Same - # rationale as the git-hook bodies: portable command name, - # auto-tracks the installed wheel's scripts. - "command": "cmind", - "args": ["script", "update_graphs.py", "status"], + "type": "process", + # Process tasks avoid shell parsing and use this installation directly. + "command": command[0], + "args": command[1:], "presentation": { "echo": False, "reveal": "silent", @@ -2840,17 +2916,28 @@ def _install_copilot_hooks(project_path: Path) -> None: existing["tasks"] = tasks_list tasks_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") - # Tasks file is workspace-specific (absolute Python path); it is - # ignored via :func:`_setup_gitignore`, which runs earlier in the - # init flow. + # Workspace integration preferences are ignored via _setup_gitignore, + # which runs earlier in the init flow. + + +_GIT_HOOK_CLEANUP_ERROR = ( + "Could not complete CoderMind Git hook cleanup. " + "Check pre-commit, post-commit and post-merge files and permissions, then retry." +) +_GIT_HOOK_INSTALL_ERROR = ( + "Could not install CoderMind Git sync hooks. " + "Check post-commit and post-merge files and permissions, then retry." +) def _install_hooks( project_path: Path, selected_ai: str, tracker=None, + *, + git_hooks: bool = False, ) -> None: - """Install RPG auto-update hooks for the selected AI assistant. + """Install status integrations and reconcile opt-in, sync-only Git hooks. - Claude: merges a ``SessionStart`` hook into ``.claude/settings.json`` that runs ``update_graphs.py status`` so stdout (RPG stats + MCP @@ -2859,18 +2946,47 @@ def _install_hooks( ``.vscode/tasks.json`` that runs the same status command on workspace open — VS Code's closest analogue to a SessionStart hook for GitHub Copilot. - - All: installs an RPG sync trigger on ``.git/hooks/post-commit`` - (fired after every successful commit) AND ``.git/hooks/post-merge`` - (fired after ``git pull`` / ``git merge`` so teammate-incoming - changes get picked up immediately). Any legacy ``pre-commit`` - block from earlier releases is stripped on upgrade — the design - now relies on post-commit only, so commit latency stays low and - the inner-git history is cleaner. - Complements the MCP server already registered in - ``.mcp.json`` / ``.vscode/mcp.json``. + - Git hooks default to disabled: remove only CoderMind-owned pre-commit, + post-commit and post-merge blocks, including known legacy bodies. + User hooks are preserved. ``git_hooks=True`` opts into deterministic + sync dispatchers for post-commit/post-merge, never background AI work. + Retired pre-commit blocks are removed in either mode. + - Cleanup attempts every known hook independently in both modes. Any + cleanup failure prevents installation; cleanup/installation failures + raise RuntimeError so init/update cannot report success or encode. + Status integration failures remain best-effort after reconciliation. """ + # Do not let one unreadable hook leave other retired/AI hooks active. + # Opt-in installs also wait until all old hook cleanup is certain. + cleanup_failed = False + for hook_name in _LEGACY_GIT_HOOK_BLOCKS: + try: + if hook_name == "pre-commit": + _uninstall_git_pre_commit_hook(project_path) + else: + _uninstall_git_hook(project_path, hook_name) + except Exception: + cleanup_failed = True + if cleanup_failed: + if tracker: + tracker.error("hooks", _GIT_HOOK_CLEANUP_ERROR) + raise RuntimeError(_GIT_HOOK_CLEANUP_ERROR) from None + + installed = [] + if git_hooks is True: + try: + if _install_git_post_commit_hook(project_path): + installed.append("git:post-commit (sync only)") + if _install_git_post_merge_hook(project_path): + installed.append("git:post-merge (sync only)") + except Exception: + if tracker: + tracker.error("hooks", _GIT_HOOK_INSTALL_ERROR) + raise RuntimeError(_GIT_HOOK_INSTALL_ERROR) from None + else: + installed.append("git hooks disabled (cmind blocks removed)") + try: - installed = [] if selected_ai == "claude": _install_claude_hooks(project_path) installed.append("claude") @@ -2878,13 +2994,6 @@ def _install_hooks( _install_copilot_hooks(project_path) installed.append("copilot") - # Strip any leftover pre-commit block from older installs. - _uninstall_git_pre_commit_hook(project_path) - if _install_git_post_commit_hook(project_path): - installed.append("git:post-commit") - if _install_git_post_merge_hook(project_path): - installed.append("git:post-merge") - if tracker: if installed: tracker.complete("hooks", ", ".join(installed)) @@ -2894,7 +3003,7 @@ def _install_hooks( if tracker: tracker.error("hooks", str(e)) else: - console.print(f"[yellow]Warning: Could not install hooks: {e}[/yellow]") + console.print(f"[yellow]Warning: Could not reconcile hooks: {e}[/yellow]") def _is_private_repo( @@ -3664,7 +3773,7 @@ def init( ai_assistant: str = typer.Option( None, "--ai", - help="AI assistant to use: copilot or claude", + help="Explicit user-local AI provider and integration: copilot or claude", ), script_type: str = typer.Option( None, "--script", help="Script type to use: sh or ps" @@ -3723,12 +3832,19 @@ def init( False, "--no-cmind-git", help=( - "Skip initialising a private git repository inside .cmind/. " - "Default is ON: cmind init seeds .cmind/.git " - "so every subsequent `cmind script` invocation auto-snapshots " - "the workspace state, letting you `git log` / `git diff` " - "between pipeline stages without extra tooling. This flag " - "disables the feature for the current init only." + "Skip initialising the home-side private snapshot repository at " + "~/.cmind/workspaces//.git. By default eligible pipeline " + "stages snapshot generated state there. This flag skips initialization " + "for this invocation; it does not disable an existing snapshot repo " + "or control project Git sync hooks." + ), + ), + git_hooks: bool = typer.Option( + False, + "--git-hooks/--no-git-hooks", + help=( + "Opt into deterministic post-commit/post-merge sync (never AI calls). " + "Default: remove CoderMind-owned Git hook blocks; preserve user hooks." ), ), ): @@ -3776,7 +3892,21 @@ def init( if here: project_name = Path.cwd().name project_path = Path.cwd() + else: + project_path = Path(project_name).resolve() + + _preflight_workspace_policy(project_path) + explicit_ai = isinstance(ai_assistant, str) + if not explicit_ai and not sys.stdin.isatty(): + console.print( + "Error: non-interactive init requires --ai copilot or --ai claude. " + "Repository recommendations and detected integrations are not execution consent.", + style="red", markup=False, + ) + raise typer.Exit(1) + + if here: existing_items = list(project_path.iterdir()) if existing_items: console.print( @@ -3795,7 +3925,6 @@ def init( console.print("[yellow]Operation cancelled[/yellow]") raise typer.Exit(0) else: - project_path = Path(project_name).resolve() if project_path.exists(): error_panel = Panel( f"Directory '[cyan]{project_name}[/cyan]' already exists\n" @@ -3830,7 +3959,7 @@ def init( "[yellow]Git not found - will skip repository initialization[/yellow]" ) - if ai_assistant: + if explicit_ai: if ai_assistant not in AGENT_CONFIG: console.print( f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}" @@ -3841,7 +3970,7 @@ def init( # Create options dict for selection (agent_key: display_name) ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} selected_ai = select_with_arrows( - ai_choices, "Choose your AI assistant:", "copilot" + ai_choices, "Choose AI provider to save locally (also configures integration):", "copilot" ) if not ignore_agent_tools: @@ -3892,7 +4021,8 @@ def init( else: selected_script = default_script - console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") + console.print(f"[cyan]Selected AI integration:[/cyan] {selected_ai}") + console.print("[dim]Explicit provider choice will be saved locally after hooks succeed.[/dim]") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") tracker = StepTracker("Initialize CoderMind Project") @@ -3901,7 +4031,7 @@ def init( tracker.add("precheck", "Check required tools") tracker.complete("precheck", "ok") - tracker.add("ai-select", "Select AI assistant") + tracker.add("ai-select", "Choose local AI provider and integration") tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) @@ -3917,7 +4047,7 @@ def init( ("copilot-cli-mcp", "Register rpg-tools in ~/.copilot/mcp-config.json"), ("cleanup", "Cleanup"), ("git", "Initialize git repository"), - ("hooks", "Install auto-update hooks"), + ("hooks", "Configure status integrations and opt-in Git sync hooks"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -3940,12 +4070,11 @@ def init( debug=debug, ) - # .cmind/.source is written by whichever provisioning path - # actually ran (_install_from_bundle / _download_and_extract_release_zip). + # Provisioning records the channel in home-side .meta.toml, + # separately from user-local execution selection. - # Materialise .cmind/config.toml with the resolved AI CLI - # command. llm_client.py reads this at runtime to invoke - # the right sub-agent. + # Track only a marker/recommendation, never execution consent. + # Preserve valid pre-existing configs byte-for-byte. _write_workspace_config(project_path, selected_ai) # Materialize .gitignore *before* MCP/hook generation so the @@ -3995,9 +4124,16 @@ def init( else: tracker.skip("git", "--no-git flag") - _install_hooks(project_path, selected_ai, tracker=tracker) + _install_hooks(project_path, selected_ai, tracker=tracker, git_hooks=git_hooks) + # Reaching this point requires --ai or an interactive init choice. + # Do not authorize execution if hook migration failed. + _save_local_ai_selection(project_path, selected_ai) tracker.complete("final", "project ready") + except _ai_cli_policy().AICommandPolicyError: + tracker.error("final", _AI_POLICY_ERROR) + console.print(_AI_POLICY_ERROR, style="red", markup=False) + raise typer.Exit(1) from None except Exception as e: tracker.error("final", str(e)) console.print( @@ -4139,7 +4275,7 @@ def init( "", " For existing repositories / code-to-RPG:", f" {step_num}.6 [cyan]/cmind.encode[/] - Encode an existing repo into RPG", - f" {step_num}.7 [cyan]/cmind.update_rpg[/] - Manual incremental RPG update fallback", + f" {step_num}.7 [cyan]/cmind.update_rpg[/] - Explicit AI-driven incremental RPG update", f" {step_num}.8 [dim][Optional][/dim] [cyan]/cmind.rpg_edit [/] - Surgical RPG/code edit", "", " For finer-grained commands and stage-by-stage reruns, see:", @@ -4166,7 +4302,8 @@ def init( steps_lines.append( " [yellow]Note:[/] the MCP tools query [cyan]rpg.json[/] in the workspace's home-dir " "store, which is created by the encoder. For existing codebases, run [cyan]/cmind.encode[/] " - "once now to populate it; the post-commit hook keeps it in sync afterwards." + "once now to populate it. Git sync hooks require [cyan]--git-hooks[/] on each init/update; " + "AI-driven updates require an explicit [cyan]cmind script update_graphs.py update-rpg[/] invocation." ) steps_panel = Panel( @@ -4179,19 +4316,19 @@ def init( if selected_ai == "claude": claude_settings = project_path / ".claude" / "settings.json" permissions_hint = Panel( - f"The template pre-configures [cyan].claude/settings.json[/cyan] with broad permissions " - f"(e.g. [cyan]Bash[/cyan], [cyan]Write[/cyan], [cyan]Edit[/cyan]) so that Claude Code can run scripts and " - f"modify files without repeated approval prompts.\n" - f"These permissions may be more permissive than you need. " - f"You can review and adjust them at any time by editing [cyan]{claude_settings.relative_to(project_path)}[/cyan].", - title="[yellow]Pre-granted Permissions[/yellow]", + f"The status integration adds [cyan]mcp__rpg-tools[/cyan] for read-only graph queries " + f"and preserves existing settings. It does not grant blanket Bash, Write or Edit access.\n" + f"AI calls keep the provider's normal permission checks. Existing or legacy template " + f"permissions may still be broad; this update does not revoke them. Review " + f"[cyan]{claude_settings.relative_to(project_path)}[/cyan] separately.", + title="[yellow]Review Assistant Permissions[/yellow]", border_style="yellow", padding=(1, 2), ) console.print() console.print(permissions_hint) - # Initialise the private snapshot repo inside .cmind/. Done BEFORE + # Initialise the private snapshot repo in the home-side RPG store. Done BEFORE # the optional initial encode so the encoder's output, if it runs, # becomes a fresh commit on top of the [init] baseline — a useful # diff target. @@ -4227,7 +4364,10 @@ def update( ai_assistant: str = typer.Option( None, "--ai", - help="AI assistant to use (auto-detected from existing project if not specified)", + help=( + "Explicitly save a user-local AI provider and select its integration. " + "Without --ai, refresh integrations only; local execution consent is unchanged." + ), ), script_type: str = typer.Option( None, "--script", help="Script type to use: sh or ps" @@ -4267,19 +4407,32 @@ def update( False, "--no-cmind-git", help=( - "Skip backfilling the private snapshot repo at .cmind/.git " + "Skip backfilling the private snapshot repo at " + "~/.cmind/workspaces//.git " "for older workspaces that don't have one yet. Default is ON: " "if the inner repo is missing, `cmind update` creates it and " "commits a catch-up snapshot. Pre-existing inner repos are " "never touched." ), ), + git_hooks: bool = typer.Option( + False, + "--git-hooks/--no-git-hooks", + help=( + "Opt into deterministic post-commit/post-merge sync (never AI calls). " + "Default: remove CoderMind-owned Git hook blocks; preserve user hooks." + ), + ), ): """Update CoderMind template files in an existing project to the latest version. This command updates scripts, templates, command definitions, MCP - config, gitignore rules, and git hooks in the current directory. - It auto-detects the AI assistant from existing project configuration. + config and gitignore rules in the current directory. Git hooks are + disabled unless --git-hooks is supplied; only CoderMind-owned blocks + are removed by default. Opt-in hooks perform deterministic sync only. + Without --ai, it prefers the existing local provider for templates, then + detects agent directories; neither detection nor a repository recommendation + grants execution consent. Only explicit --ai changes the local provider. Equivalent to re-running 'cmind init --here --force' but with proper semantics and automatic detection of existing settings. @@ -4292,6 +4445,7 @@ def update( show_banner() project_path = Path.cwd() + _preflight_workspace_policy(project_path) # Verify this is an existing CoderMind project cmind_dir = project_path / ".cmind" @@ -4308,8 +4462,9 @@ def update( ) raise typer.Exit(1) - # Determine AI assistant - if ai_assistant: + # Integration selection and execution consent are deliberately separate. + explicit_ai = isinstance(ai_assistant, str) + if explicit_ai: if ai_assistant not in AGENT_CONFIG: console.print( f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. " @@ -4318,18 +4473,37 @@ def update( raise typer.Exit(1) selected_ai = ai_assistant else: - detected = _detect_ai_agent(project_path) - if detected: + policy = _ai_cli_policy() + try: + local_provider = policy.read_local_provider(project_path) + except (policy.AICommandPolicyError, OSError): + console.print(_LOCAL_AI_SELECTION_ERROR, style="red", markup=False) + raise typer.Exit(1) from None + if local_provider in AGENT_CONFIG: + selected_ai = local_provider console.print( - f"[cyan]Auto-detected AI assistant:[/cyan] {detected} " - f"({AGENT_CONFIG[detected]['name']})" + f"[cyan]Using existing user-local provider for integration:[/cyan] {selected_ai}" ) - selected_ai = detected else: - ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} - selected_ai = select_with_arrows( - ai_choices, "Choose your AI assistant:", "copilot" - ) + detected = _detect_ai_agent(project_path) + if detected in AGENT_CONFIG: + selected_ai = detected + console.print( + f"[cyan]Auto-detected AI integration (not execution consent):[/cyan] {detected}" + ) + elif not sys.stdin.isatty(): + console.print( + "Error: cannot determine AI integration in non-interactive update. " + "Pass --ai copilot or --ai claude to explicitly select and save a local provider, " + "or run interactively to choose an integration only.", + style="red", markup=False, + ) + raise typer.Exit(1) + else: + ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} + selected_ai = select_with_arrows( + ai_choices, "Choose AI integration only (local provider unchanged):", "copilot" + ) # Determine script type if script_type: @@ -4362,7 +4536,14 @@ def update( else: selected_script = default_script - console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") + console.print(f"[cyan]Selected AI integration:[/cyan] {selected_ai}") + if explicit_ai: + console.print("[dim]Explicit provider choice will be saved locally after hooks succeed.[/dim]") + else: + console.print( + "[dim]Integration only: user-local AI selection will not be changed. " + "Use --ai copilot or --ai claude to explicitly save a provider.[/dim]" + ) console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") # Pre-update CLI upgrade ------------------------------------------------- @@ -4382,8 +4563,8 @@ def update( # ``--no-upgrade`` skips this step (offline / pinned CI / freshly # re-installed manually). # - # After a successful upgrade we ``os.execvp`` the (now-upgraded) - # cmind binary so the rest of update runs against the freshly + # After a successful upgrade we re-enter the absolute installed CLI + # bootstrap so the rest of update runs against the freshly # installed code + assets. Mixing old in-memory logic with new # on-disk core_pack/ used to cause logic vs assets drift bugs. # @@ -4425,6 +4606,8 @@ def update( f"[cyan]Upgrading cmind-cli via {method} (source={source})...[/cyan]" ) try: + if cmd[0] in ("uv", "pipx"): + cmd = [_resolve_tool(cmd[0], project_path), *cmd[1:]] rc = subprocess.call(cmd) # type: ignore[arg-type] except FileNotFoundError: # Upgrade tool (uv, pipx, pip) not on PATH — surface, then @@ -4451,16 +4634,16 @@ def update( # loop-guard env var so the re-exec'd process doesn't # immediately try to upgrade again. new_argv = list(sys.argv) - cmind_bin = shutil.which("cmind") or new_argv[0] console.print( "[cyan]CLI upgrade complete; re-exec'ing to apply " "new templates...[/cyan]" ) try: os.environ[_UPGRADE_DONE_ENV] = "1" - os.execvp(cmind_bin, [cmind_bin, *new_argv[1:]]) + command = _cli_argv(*new_argv[1:]) + os.execv(command[0], command) except OSError as exc: - # execvp failed — fall back to running the update + # execv failed — fall back to running the update # in-process with the (now-on-disk) new code. This # mixes old in-memory logic with new assets, but # that's strictly better than crashing here: the user @@ -4487,7 +4670,7 @@ def update( sys._cmind_tracker_active = True - tracker.add("ai-select", "Select AI assistant") + tracker.add("ai-select", "Select AI integration") tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) @@ -4501,7 +4684,7 @@ def update( ("gitignore", "Configure .gitignore"), ("mcp", "Configure MCP server"), ("copilot-cli-mcp", "Register rpg-tools in ~/.copilot/mcp-config.json"), - ("hooks", "Install auto-update hooks"), + ("hooks", "Configure status integrations and opt-in Git sync hooks"), ("cleanup", "Cleanup"), ("final", "Finalize"), ]: @@ -4522,11 +4705,11 @@ def update( debug=debug, ) - # .cmind/.source is written by whichever provisioning path - # actually ran (_install_from_bundle / _download_and_extract_release_zip). + # Provisioning records the channel in home-side .meta.toml, + # separately from user-local execution selection. - # Refresh .cmind/config.toml only when missing (preserves - # user customisations on re-update). + # Revalidate repository recommendations and create only when absent; + # preserve valid hints, including exact legacy built-in commands. _write_workspace_config(project_path, selected_ai) # Pre-create runtime directories so stage prompts that redirect @@ -4563,11 +4746,18 @@ def update( tracker.start("copilot-cli-mcp") _register_copilot_cli_global_mcp(tracker=tracker) - # Reconcile hook files so existing workspaces receive the - # current post-commit/post-merge dispatcher contract. - _install_hooks(project_path, selected_ai, tracker=tracker) + # Remove cmind Git blocks by default, or install sync-only + # dispatchers when this invocation explicitly opts in. + _install_hooks(project_path, selected_ai, tracker=tracker, git_hooks=git_hooks) + # Detection and interactive update choices refresh templates only. + if explicit_ai: + _save_local_ai_selection(project_path, selected_ai) tracker.complete("final", "update complete") + except _ai_cli_policy().AICommandPolicyError: + tracker.error("final", _AI_POLICY_ERROR) + console.print(_AI_POLICY_ERROR, style="red", markup=False) + raise typer.Exit(1) from None except Exception as e: tracker.error("final", str(e)) console.print( @@ -4607,7 +4797,7 @@ def update( ) # Backfill inner snapshot repo for workspaces created before - # this feature shipped. Idempotent — does nothing if .cmind/.git + # this feature shipped. Idempotent — does nothing if the home-side snapshot repo # already exists, and silently noops if --no-cmind-git was passed. if not no_cmind_git: from . import _inner_git @@ -4811,16 +5001,13 @@ def _resolve_script_path(relpath: str) -> Optional[Path]: # --------------------------------------------------------------------------- # # Python entry-point for git hooks. The on-disk hook files in -# ``.git/hooks/`` are short shell stubs that ``exec`` this command; -# path resolution, logging, locking, and detach logic live here so they +# ``.git/hooks/`` are opt-in shell stubs that call this command; +# path resolution, logging, and deterministic sync live here so they # can be updated by upgrading the CLI rather than reinstalling hooks. _HOOK_ENV_NAME = "CMIND_HOOK" _HOOK_ENV_SHA = "CMIND_HOOK_SHA" _HOOK_LOG_FILENAME = "hooks.log" -_HOOK_BACKGROUND_LOG = "update_rpg.log" -_HOOK_LOCK_DIRNAME = ".update_rpg.lock" -_HOOK_LOCK_STALE_SECONDS = 60 * 60 # 60 minutes -- matches the old shell impl def _hook_log_line(log_path: Path, msg: str) -> None: @@ -4840,7 +5027,7 @@ def _short_head_sha(workspace: Path) -> str: """Return ``git rev-parse --short HEAD`` for ``workspace`` or ``"?"``.""" try: r = subprocess.run( - ["git", "-C", str(workspace), "rev-parse", "--short", "HEAD"], + [_resolve_git(workspace), "-C", str(workspace), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5, ) if r.returncode == 0: @@ -4857,12 +5044,12 @@ def _hook_run_foreground( script_args: List[str], label: str, ) -> int: - """Run ``cmind script `` and tee output into ``log_path``.""" + """Run ``cmind script `` and append output to ``log_path``.""" _hook_log_line(log_path, f"{label}: start ({' '.join(script_args)})") try: with open(log_path, "a", encoding="utf-8") as fh: proc = subprocess.run( - ["cmind", "script", *script_args], + _cli_argv("script", *script_args), cwd=str(workspace), env=env, stdout=fh, stderr=subprocess.STDOUT, @@ -4875,82 +5062,6 @@ def _hook_run_foreground( return -1 -def _hook_spawn_background( - workspace: Path, - home_dir: Path, - hook_log: Path, - env: Dict[str, str], -) -> None: - """Acquire a directory lock and detach ``update_graphs.py update-rpg``. - - The lock is a *directory* (``mkdir`` is the only POSIX-atomic - exclusive-create primitive); a directory older than - :data:`_HOOK_LOCK_STALE_SECONDS` is treated as orphaned (worker - killed by OOM / reboot / SIGKILL) and removed before re-trying. - """ - lock_dir = home_dir / "logs" / _HOOK_LOCK_DIRNAME - bg_log = home_dir / "logs" / _HOOK_BACKGROUND_LOG - - # Stale-lock recovery -- match the 60-minute window the shell hook used. - try: - if lock_dir.is_dir(): - age = time.time() - lock_dir.stat().st_mtime - if age > _HOOK_LOCK_STALE_SECONDS: - shutil.rmtree(lock_dir, ignore_errors=True) - _hook_log_line(hook_log, f"phase2: removed stale lock (age={age:.0f}s)") - except OSError: - pass - - # Try to acquire. - try: - lock_dir.mkdir(parents=False, exist_ok=False) - except FileExistsError: - _hook_log_line(hook_log, "phase2: skipped (another worker holds the lock)") - return - except OSError as exc: - _hook_log_line(hook_log, f"phase2: lock acquire failed: {exc!r}") - return - - # Background worker: run update-rpg, then release the lock. We - # cannot use ``Popen`` alone because nothing would ``rmdir`` the - # lock after the worker completes; a tiny ``sh -c`` wrapper does - # the cleanup deterministically. - # - # ``start_new_session=True`` is the cross-platform equivalent of - # ``nohup``/``setsid`` -- the child survives the hook's exit. - bg_log.parent.mkdir(parents=True, exist_ok=True) - lock_q = shlex.quote(str(lock_dir)) - log_q = shlex.quote(str(bg_log)) - workspace_q = shlex.quote(str(workspace)) - shell_cmd = ( - f"cd {workspace_q}; sleep 2; " - f"cmind script update_graphs.py update-rpg --json >> {log_q} 2>&1; " - f"rmdir {lock_q}" - ) - # Strip GIT_INDEX_FILE / GIT_DIR which git sets during hooks - - # if they leak into the worker, ``git worktree add`` fails with - # cryptic index errors. - worker_env = {k: v for k, v in env.items() if k not in ("GIT_INDEX_FILE", "GIT_DIR")} - try: - subprocess.Popen( - ["sh", "-c", shell_cmd], - cwd=str(workspace), - env=worker_env, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - _hook_log_line(hook_log, f"phase2: dispatched -> {bg_log}") - except OSError as exc: - _hook_log_line(hook_log, f"phase2: spawn failed: {exc!r}") - # Release the lock so the next commit can retry. - try: - lock_dir.rmdir() - except OSError: - pass - - @app.command( "hook", hidden=True, @@ -4961,7 +5072,9 @@ def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-mer Resolves the current workspace via the standard cwd-walk, attaches a hook log under ``~/.cmind/workspaces//logs/hooks.log``, - and runs the per-hook orchestration. Every failure path is + and runs deterministic sync only, never an AI update or background + worker. Explicit AI updates use ``cmind script update_graphs.py update-rpg`` + outside hook context. Every failure path is swallowed (logged, never raised) so a misbehaving hook never blocks the user's git operation. @@ -4973,8 +5086,8 @@ def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-mer All ``cmind script`` subprocess invocations inherit two env vars: - * ``CMIND_HOOK`` -- the hook name (``post-commit`` etc.) - * ``CMIND_HOOK_SHA`` -- short SHA of the user-facing commit + The ``CMIND_HOOK`` marker carries the hook name and blocks LLMClient + AI calls. ``CMIND_HOOK_SHA`` carries the short user-facing commit SHA. The inner-git snapshot's commit message picks these up (:func:`cmind_cli._inner_git._build_message`) so ``git log`` in the @@ -4993,7 +5106,6 @@ def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-mer # then un-init'd, and we never want to block git. raise typer.Exit(0) - home_dir = _storage.home_workspace_dir(ws) log_path = _storage.workspace_logs_dir(ws) / _HOOK_LOG_FILENAME sha = _short_head_sha(ws) @@ -5026,8 +5138,6 @@ def hook(name: str = typer.Argument(..., help="Hook name: post-commit | post-mer ["update_graphs.py", "sync"], "foreground-sync", ) - # The LLM-driven RPG update runs detached from git commit. - _hook_spawn_background(ws, home_dir, log_path, env) else: _hook_log_line(log_path, f"unknown hook name: {name!r}") raise typer.Exit(0) diff --git a/CoderMind/src/cmind_cli/_cli_entry.py b/CoderMind/src/cmind_cli/_cli_entry.py new file mode 100644 index 0000000..0d57a26 --- /dev/null +++ b/CoderMind/src/cmind_cli/_cli_entry.py @@ -0,0 +1,16 @@ +"""Absolute-path subprocess bootstrap; never import the CLI from the workspace.""" + +from pathlib import Path +import sys + + +if __name__ == "__main__": + # -I ignores PYTHONIOENCODING/PYTHONUTF8. Preserve the CLI's UTF-8 contract + # for redirected Windows output without trusting interpreter environment. + for stream in (sys.stdin, sys.stdout, sys.stderr): + if stream is not None and hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from cmind_cli import main + + main() diff --git a/CoderMind/src/cmind_cli/_inner_git.py b/CoderMind/src/cmind_cli/_inner_git.py index e72b3fe..53e3ced 100644 --- a/CoderMind/src/cmind_cli/_inner_git.py +++ b/CoderMind/src/cmind_cli/_inner_git.py @@ -180,8 +180,12 @@ def has_inner_git(workspace: Path) -> bool: def _git_available() -> bool: - from shutil import which - return which("git") is not None + from ._trusted_tools import resolve_git + try: + resolve_git(find_workspace_root() or Path.cwd()) + return True + except OSError: + return False def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30) -> subprocess.CompletedProcess[str]: @@ -203,7 +207,8 @@ def _run_git(workspace: Path, *args: str, check: bool = False, timeout: int = 30 # corrupted (entries from $HOME/.cmind get written into the outer index.lock). for _v in ("GIT_INDEX_FILE", "GIT_DIR", "GIT_WORK_TREE", "GIT_OBJECT_DIRECTORY"): env.pop(_v, None) - cmd = ["git", "-C", str(_inner_git_dir(workspace))] + list(args) + from ._trusted_tools import resolve_git + cmd = [resolve_git(workspace), "-C", str(_inner_git_dir(workspace))] + list(args) return subprocess.run( cmd, check=check, diff --git a/CoderMind/src/cmind_cli/_trusted_tools.py b/CoderMind/src/cmind_cli/_trusted_tools.py new file mode 100644 index 0000000..a48dfaf --- /dev/null +++ b/CoderMind/src/cmind_cli/_trusted_tools.py @@ -0,0 +1,49 @@ +"""Load the shared tool resolver only from this installation's trusted assets.""" + +from functools import lru_cache +import importlib.util +from pathlib import Path +import shlex +import sys + +from . import _assets + + +@lru_cache(maxsize=1) +def _policy(): + path = (_assets.scripts_dir() / "common" / "trusted_tools.py").resolve() + spec = importlib.util.spec_from_file_location("_cmind_trusted_tools", path) + if spec is None or spec.loader is None: + raise RuntimeError("Cannot load the installed tool resolver") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def resolve_tool(name: str, workspace: Path | str) -> str: + return _policy().resolve_tool(name, workspace) + + +def resolve_git(workspace: Path | str) -> str: + return resolve_tool("git", workspace) + + +def cli_argv(*args: str) -> list[str]: + """Re-enter this CLI using its interpreter and pinned package bootstrap. + + -I prevents cwd/PYTHONPATH from selecting a different cmind_cli module. + The bootstrap also supports the explicitly installed editable source tree. + """ + entry = Path(__file__).resolve().with_name("_cli_entry.py") + if not entry.is_file(): + raise FileNotFoundError("Installed CoderMind CLI entry is missing") + return [sys.executable, "-I", str(entry), *args] + + +def cli_shell_command(*args: str) -> str: + """Quote the pinned entry for Git/Claude's POSIX shell, including Git Bash.""" + command = cli_argv(*args) + return shlex.join([ + Path(value).as_posix() if index in (0, 2) else value + for index, value in enumerate(command) + ]) diff --git a/CoderMind/templates/commands/build_data_flow.md b/CoderMind/templates/commands/build_data_flow.md index 0c7b55c..961e00b 100644 --- a/CoderMind/templates/commands/build_data_flow.md +++ b/CoderMind/templates/commands/build_data_flow.md @@ -14,6 +14,13 @@ proceed with default behavior. All the bash command timeout is set to 1 hour. +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## **Outline** The text entered by the user after `/cmind.build_data_flow` **is the adjustment suggestion**. diff --git a/CoderMind/templates/commands/build_skeleton.md b/CoderMind/templates/commands/build_skeleton.md index 9c5c6c3..16c918b 100644 --- a/CoderMind/templates/commands/build_skeleton.md +++ b/CoderMind/templates/commands/build_skeleton.md @@ -12,6 +12,13 @@ $ARGUMENTS You **MAY** consider additional user input if provided. If empty, proceed with default behavior. +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## **Outline** The text entered by the user after `/cmind.build_skeleton` **is the adjustment suggestion**. diff --git a/CoderMind/templates/commands/code_gen.md b/CoderMind/templates/commands/code_gen.md index 4d54299..8d3bf62 100644 --- a/CoderMind/templates/commands/code_gen.md +++ b/CoderMind/templates/commands/code_gen.md @@ -11,6 +11,19 @@ runs pytest, and fixes issues — up to 5 iterations per attempt, 2 attempts per **Remember to re-read this document after context compact!** +## Execution Prerequisites and Security Stops + +AI calls require a trusted constructor/process choice or valid user-local +selection; tracked provider hints are not authority. Normal provider +approvals apply. **Configuration, authentication, access, or approval blocks +override every autonomous, retry, repair, resume, and `next_action` instruction +below, including sub-agent work.** Show the exact error and any report path, +preserve artifacts and branches, and pause for the user. Do not retry, advance +to another batch, modify code/state/configuration, run init/update, or grant +trust/permission overrides as recovery. These are not checkpoint or ordinary +test failures. Resume only after the user resolves the blocker and explicitly +requests another attempt. + ## Workflow ### Step 1: Pre-Check @@ -23,7 +36,8 @@ cmind script check_code_gen.py --json **If type is "error"**: -* Fix the reported issues or run the prerequisite command first +* Apply the security stop rule first. For ordinary artifact/prerequisite + issues, fix the reported issue or run the prerequisite command as appropriate. **If type is "init"**: @@ -71,15 +85,16 @@ Remember both choices for the session. ## Main Loop: Step 4 > **⚠️ CRITICAL: Autonomous Continuous Operation** -> This workflow MUST run autonomously in a tight loop. **NEVER stop, pause, or wait -> for user input between batches.** After completing one batch, IMMEDIATELY proceed -> to the next. The only acceptable reasons to stop are: +> Subject to the security stop rule above, run continuously between batches +> without unnecessary prompts. After completing one batch, proceed to the next +> unless a stop condition applies. Acceptable reasons to stop include: > +> * Configuration, authentication, access, or provider approval blocks > * Unrecoverable errors (merge conflicts, script crashes) > * All tasks completed > > Brief one-line status is OK (e.g., "Batch 5 done, 15/179 tasks, continuing...") -> but you MUST immediately run the next command. +> and should be followed by the next command only when no stop condition applies. ### Step 4: Run Batches @@ -89,9 +104,12 @@ Remember both choices for the session. 2. Sets up the dev venv (`.venv_dev/`) with pytest + pytest-timeout 3. Dispatches a sub-agent that autonomously runs the TDD cycle 4. Post-verifies by running its own independent pytest -5. On failure: auto-retries once with resume context +5. On ordinary implementation/test failure: auto-retries once with resume context 6. On success: merges branch into main, updates state -7. On final failure: preserves branch, marks failed, continues +7. On final implementation/test failure: preserves branch, marks failed, continues + +If output reveals a security stop, do not dispatch more work or reinterpret it +as a resumable batch failure, regardless of the suggested `next_action`. **Single-batch mode:** @@ -116,11 +134,11 @@ cmind script run_batch.py --next --merge-file --max-units --json | Field | Meaning | | --------------------------- | -------------------------------------------------------------------- | | `type: "batch_complete"` | Batch passed. Check `next_action` and continue. | -| `type: "batch_failed"` | Batch failed after 2 attempts. Branch preserved. Continue to next. | +| `type: "batch_failed"` | Ordinary batch failure after 2 attempts: branch preserved; continue only if no security stop occurred. | | `type: "complete"` | All tasks done. Proceed to Step 5. | -| `success: false` + `error` | Script error. Fix and retry. | +| `success: false` + `error` | Show the exact error. Security blocks stop; only ordinary implementation failures enter fix/retry. | -**After each batch completes, IMMEDIATELY run the same command again for the next batch.** +**After each batch completes, run the same command for the next batch only if no security stop occurred.** Continue until `type` is `"complete"` or no tasks remain. @@ -196,13 +214,14 @@ cmind script run_batch.py --global-review --json ## Recovery -To resume from any state: +To inspect an ordinary interrupted state (not to bypass a security stop): ```bash cmind script check_code_gen.py --json ``` -Follow the `next_action` field — it always tells you the exact command to run. +Follow the `next_action` field only if the security stop rule does not apply; +it is guidance, not authorization to retry a blocked operation. State is persisted in `.cmind/data/code_gen_state.jsonl`. ## Notes @@ -211,4 +230,5 @@ State is persisted in `.cmind/data/code_gen_state.jsonl`. * Failed batches preserve their branch for manual inspection * The dev venv at `.venv_dev/` is shared across all batches * Sub-agents can install dependencies and update requirements.txt incrementally -* `run_batch.py` does NOT require manual intervention between steps +* Ordinary batches need no manual intervention between steps; provider approvals + and security stops still require the user and must never be bypassed. diff --git a/CoderMind/templates/commands/design_base_classes.md b/CoderMind/templates/commands/design_base_classes.md index db15d2d..b34ae08 100644 --- a/CoderMind/templates/commands/design_base_classes.md +++ b/CoderMind/templates/commands/design_base_classes.md @@ -12,6 +12,13 @@ $ARGUMENTS You **MAY** consider additional user input if provided. If empty, proceed with default behavior. +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## **Outline** The text entered by the user after `/cmind.design_base_classes` **is the adjustment suggestion**. diff --git a/CoderMind/templates/commands/design_interfaces.md b/CoderMind/templates/commands/design_interfaces.md index 6e28518..7fd1d95 100644 --- a/CoderMind/templates/commands/design_interfaces.md +++ b/CoderMind/templates/commands/design_interfaces.md @@ -9,6 +9,13 @@ All the bash command timeout is set to 1 hour. Design function and class interfaces for your repository files based on the skeleton structure. +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## Workflow ### Step 1: Pre-Check diff --git a/CoderMind/templates/commands/encode.md b/CoderMind/templates/commands/encode.md index 4292e03..d90d707 100644 --- a/CoderMind/templates/commands/encode.md +++ b/CoderMind/templates/commands/encode.md @@ -18,6 +18,14 @@ Encode the current repository into an RPG structure. The RPG captures the codebase's functional architecture as a graph of nodes (features, modules, code entities) and edges (dependencies, containment). +**Execution prerequisites:** AI calls require a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not +authority. Normal provider approvals apply. Configuration, authentication, +access, or approval blocks override recovery guidance: show the exact error +and any report path, preserve artifacts, and stop for the user. Do not retry, +run init/update, rewrite configuration/local selection, or grant +trust/permission overrides as recovery. + ### Step 1: Pre-Check Run the check script to determine the current encode state: @@ -30,8 +38,11 @@ Inspect the `type` field in the output: **If type is "error"**: -* Display the error message and stop. The RPG file may be corrupted. -* Suggest deleting the invalid file and re-running `/cmind.encode`. +* Display the exact error and any diagnostic/report artifact path, and stop. + An error is not necessarily graph corruption. +* Preserve the existing graph and reports. Ask the user how to proceed; + any backup, deletion, or full rebuild requires an explicit user decision. + Never delete the graph automatically to get past this check. **If type is "init"**: @@ -85,10 +96,12 @@ Inspect the JSON `status` field to decide next steps. * Proceed to Step 3. -**If status is "error"**: +**If status is "error" or the process exits non-zero**: -* Display the error message. -* Suggest checking LLM API key configuration and repository structure. +* Display the exact error / stderr and any reported diagnostic artifact path. +* Preserve existing graph and report artifacts, and ask the user how to + proceed. Configuration, authentication, access, or approval failures are + stop conditions, not a reason to retry or alter settings automatically. ### Step 3: Next Steps @@ -99,5 +112,6 @@ Next steps: - /cmind.update_rpg — Incrementally update after code changes - The MCP server exposes search_rpg and explore_rpg tools for AI agents to query the RPG interactively. - - RPG data is saved at .cmind/data/rpg.json + - Use the reported output path or cmind version to locate RPG data + under ~/.cmind/workspaces//data/. ``` diff --git a/CoderMind/templates/commands/feature_build.md b/CoderMind/templates/commands/feature_build.md index 765b79e..6c1be64 100644 --- a/CoderMind/templates/commands/feature_build.md +++ b/CoderMind/templates/commands/feature_build.md @@ -3,6 +3,13 @@ description: Generate and iteratively refine the feature tree based on functiona name: cmind.feature_build --- +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## Workflow **Working Directory**: All relative paths and command executions are based on the project root directory diff --git a/CoderMind/templates/commands/feature_construct.md b/CoderMind/templates/commands/feature_construct.md index 8232697..6a166c5 100644 --- a/CoderMind/templates/commands/feature_construct.md +++ b/CoderMind/templates/commands/feature_construct.md @@ -1,5 +1,5 @@ --- -description: Build the complete Phase 1 feature tree from requirements in one step, with automatic resume on failure +description: Build the complete Phase 1 feature tree from requirements in one step, with resumable stages name: cmind.feature_construct --- @@ -23,6 +23,18 @@ Each stage is a standalone Python helper that drives an LLM through `LLMClient.call_structured(...)` with a Pydantic-validated schema — no intermediate Markdown artefacts are produced. +## Execution Prerequisites and Security Stops + +AI calls require a trusted constructor/process choice or valid user-local +selection; tracked provider hints are not authority. Normal provider approvals +apply. Configuration, authentication, access, or approval blocks override all +automatic resume, retry, restart, and expansion guidance below. Show the exact +error and any report path, preserve artifacts, and pause for the user. Do not +treat the block as an incomplete checkpoint, retry, modify artifacts or +configuration/local selection, run init/update, or grant trust/permission +overrides. Continue only after the user resolves the blocker and explicitly +requests another attempt. + ## Argument Parsing Supported facade options (forward as-is to the orchestrator): @@ -177,8 +189,10 @@ without modifying artefacts. Stream stdout/stderr verbatim. The orchestrator prints one progress line per stage and validates each generated artefact. -If a stage exits non-zero, surface the orchestrator's recovery hints. -Typical recovery commands: +If a stage exits non-zero, show its exact error and recovery hints. Apply the +security stop rule before any follow-up: a blocked call is not a resumable +checkpoint, and recovery hints do not authorize a retry. For ordinary stage +failures only, typical recovery commands are: ```bash cmind script feature_construct.py --check-only # see which stage failed @@ -188,8 +202,8 @@ cmind script feature_build.py --verbose # debug build stage cmind script feature_refactor.py --log-level DEBUG # debug refactor stage ``` -For spec-stage failures, the most common cause is an LLM call that -failed to produce a schema-valid JSON after retries. Re-run with +For ordinary spec-stage failures (not security blocks), a common cause is an +LLM call that failed to produce schema-valid JSON after retries. Re-run with `--verbose` to surface the trajectory file location in the script's own log output; do not attempt to locate it manually. diff --git a/CoderMind/templates/commands/feature_edit.md b/CoderMind/templates/commands/feature_edit.md index 87ee702..aeed2a3 100644 --- a/CoderMind/templates/commands/feature_edit.md +++ b/CoderMind/templates/commands/feature_edit.md @@ -24,6 +24,13 @@ If the input is empty, respond immediately: > > Usage: `/cmind.feature_edit ` +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## Workflow The text typed by the user after `/cmind.feature_edit` **is the edit instruction**. You can assume it is always available as `$ARGUMENTS`, unless explicitly empty. Do **not** ask the user to repeat it otherwise. diff --git a/CoderMind/templates/commands/feature_refactor.md b/CoderMind/templates/commands/feature_refactor.md index 371c7e8..99d078b 100644 --- a/CoderMind/templates/commands/feature_refactor.md +++ b/CoderMind/templates/commands/feature_refactor.md @@ -3,6 +3,13 @@ description: Refactor feature tree into modular component architecture name: cmind.feature_refactor --- +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## Workflow **Working Directory**: All relative paths are based on the project root directory. diff --git a/CoderMind/templates/commands/feature_spec.md b/CoderMind/templates/commands/feature_spec.md index bf8714f..b115d00 100644 --- a/CoderMind/templates/commands/feature_spec.md +++ b/CoderMind/templates/commands/feature_spec.md @@ -14,6 +14,13 @@ For the recommended end-to-end Phase 1 flow (spec → build → refactor), use `/cmind.feature_construct` instead. The granular command remains available for debugging and single-stage reruns. +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## Argument Parsing Supported facade options (forward as-is to the helper): @@ -89,7 +96,9 @@ Stream stdout / stderr from the helper. The helper handles: ### Step 5: Recovery hints on failure -If the helper exits non-zero: +If the helper exits non-zero, show the exact error first. Configuration, +authentication, access, and approval blocks follow the stop rule above, +regardless of exit code; do not retry them with `--verbose`. For other failures: - Exit code `2` (`NoInputAvailable`) — neither `--input-text` nor `docs/*.md` was found. Re-invoke with either inline text or after diff --git a/CoderMind/templates/commands/plan.md b/CoderMind/templates/commands/plan.md index 1c383b6..10d7e31 100644 --- a/CoderMind/templates/commands/plan.md +++ b/CoderMind/templates/commands/plan.md @@ -1,5 +1,5 @@ --- -description: Build the complete Phase 2 Repository Planning Graph (RPG) from the feature tree in one step, with automatic resume on failure +description: Build the complete Phase 2 Repository Planning Graph (RPG) from the feature tree in one step, with resumable stages name: cmind.plan --- @@ -17,7 +17,18 @@ If empty, proceed with default behavior. Given the feature tree produced by `/cmind.feature_construct`, this command builds the complete Repository Planning Graph (RPG) in a single -non-interactive run with automatic resume on failure. +orchestrated run with resumable stages, subject to normal provider approvals. + +### Execution prerequisites and security stops + +AI calls require a trusted constructor/process choice or valid user-local +selection; tracked provider hints are not authority. Configuration, +authentication, access, or approval blocks override all retry, resume, restart, +and non-interruption guidance below. Show the exact error and any report path, +preserve artifacts, and pause for the user. Do not treat the block as an +incomplete checkpoint, retry, modify artifacts or configuration/local selection, +run init/update, or grant trust/permission overrides. Continue only after the +user resolves the blocker and explicitly requests another attempt. > [!WARNING] > A full pipeline run can take from a few minutes to over an hour @@ -47,7 +58,7 @@ cover all `skeleton.json` features). Do not skip the stage, do not run a later stage directly, and do not create downstream artifacts from a warning-state input. -### Step 2: One decision (the only prompt of this command) +### Step 2: One workflow decision (provider approvals remain separate) Choose **exactly one** case based on `done` vs `total`: @@ -123,9 +134,11 @@ your own commentary on top of every line. ### Step 4: On failure -If the orchestrator exits non-zero, it has already printed a -`✗ ... failed` line plus three recovery hints. Surface those -hints verbatim. The most common follow-ups are: +If the orchestrator exits non-zero, surface the exact error and any +`✗ ... failed` line or recovery hints verbatim. Apply the security +stop rule before any follow-up: the hints are not authorization to retry, +probe/resume as a checkpoint, or change settings after a blocked call. +For ordinary stage failures only, the most common follow-ups are: ```bash # Re-check progress (no side effects). diff --git a/CoderMind/templates/commands/plan_tasks.md b/CoderMind/templates/commands/plan_tasks.md index 7597c3b..abc24d6 100644 --- a/CoderMind/templates/commands/plan_tasks.md +++ b/CoderMind/templates/commands/plan_tasks.md @@ -7,6 +7,13 @@ description: Plan implementation tasks from interface definitions Create implementation tasks from the interface definitions. +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints are not authority. +Normal approvals apply. Configuration, authentication, access, or approval +blocks override retry/repair guidance: show the exact error and any report path, +preserve artifacts, and stop for the user. Do not automatically retry, modify +files, run init/update, or change configuration, trust, or permissions to recover. + ## Workflow ### Step 1: Pre-Check diff --git a/CoderMind/templates/commands/rpg_edit.md b/CoderMind/templates/commands/rpg_edit.md index bb8550f..ae73980 100644 --- a/CoderMind/templates/commands/rpg_edit.md +++ b/CoderMind/templates/commands/rpg_edit.md @@ -32,6 +32,14 @@ synchronized changes across **code + RPG + dep_graph**. - Does NOT depend on `/cmind.feature_edit` or `/cmind.update_rpg`. - The RPG feature graph is the authoritative source for code modifications. +**Execution prerequisites:** AI calls need a trusted constructor/process +choice or valid user-local selection; tracked provider hints and the RPG are +not execution authority. Normal approvals apply. Configuration, authentication, +access, or approval blocks override retry/repair and optional-probe fallbacks: +show the exact error and any report path, preserve artifacts/branches, and stop +for the user. Do not automatically retry, modify files/state, run init/update, +or change configuration, trust, or permissions to recover. + ## Workflow The text after `/cmind.rpg_edit` is the edit instruction, available as `$ARGUMENTS`. @@ -89,8 +97,9 @@ separately — incorporate the results directly into Step 4. ### Step 3.5: Visual Reconnaissance (optional, before EditPlan) This step is relevant for any edit that affects **what the user sees** — -UI, layout, styles, pages, forms, or any visual component. Failure modes -short-circuit to Step 4 with no user-visible error. +UI, layout, styles, pages, forms, or any visual component. Ordinary optional +tool-availability failures may skip to Step 4; security blocks must be shown +and stop the workflow, not silently fall back. **Trigger:** Run this step when the edit instruction relates to visual or layout concerns. Match case-insensitively against: @@ -107,14 +116,15 @@ If no keyword matches, skip directly to Step 4. **Step 3.5a — Probe tool availability (≤ 5s):** ```bash -cmind script tools/browser.py check >/dev/null 2>&1 \ - && BROWSER_OK=1 || BROWSER_OK=0 +cmind script tools/browser.py check ``` -If `BROWSER_OK=0` (Playwright not installed, headless launch failed, -no display), skip the rest of this step with a one-line note like +Inspect the exit status and output without suppressing diagnostics. If the +tool is simply unavailable (Playwright not installed or no display), skip the +rest of this step with a one-line note like `Note: visual recon skipped — playwright unavailable.` and proceed to -Step 4. +Step 4. Configuration, authentication, access, or approval errors instead +follow the stop rule above. **Step 3.5b — Decide what to capture:** @@ -137,9 +147,10 @@ HTML file to extract real class names, element structure, and any inline styles. Cite these concretely in Step 4's `code_changes` descriptions instead of generic phrases like "polish the form". -**Failure handling:** If `inspect` exits non-zero (server down, 404, -network error), record the failure in your reasoning and proceed to -Step 4 without recon — never block the edit on a failed probe. +**Failure handling:** For ordinary availability errors (server down, 404, +network failure), note the error and proceed to Step 4 without recon. +Configuration, authentication, access, or approval blocks are not optional +probe failures: show the exact error and stop. ### Step 4: Generate EditPlan and Confirm @@ -234,9 +245,9 @@ Reply with one of: * `show: ` → Expand full impact / detail for this node, then ask again ``` -**This is the only user confirmation point.** Wait for one of the four -replies above before proceeding. Treat any other free-form reply as -`revise: `. +**This is the plan confirmation point, not a substitute for provider +approvals.** Wait for one of the four replies above before proceeding. +Treat any other free-form reply as `revise: `. ### Step 5: Apply Changes (RPG-First, on a dedicated branch) @@ -335,7 +346,7 @@ If the output contains a `"suggestions"` array, save it for Step 6 — these are related issues the review agent noticed but are outside the current plan's scope. Present them as follow-up recommendations. -If any test step fails: fix the code on the branch, re-run dep-refresh +For ordinary implementation/test failures only: fix the code on the branch, re-run dep-refresh (Step 5c command), `git commit --amend --no-edit` to fold the fix into the same branch commit, then re-test. diff --git a/CoderMind/templates/commands/update_rpg.md b/CoderMind/templates/commands/update_rpg.md index 1f01d38..7b9c020 100644 --- a/CoderMind/templates/commands/update_rpg.md +++ b/CoderMind/templates/commands/update_rpg.md @@ -1,6 +1,6 @@ --- name: cmind.update_rpg -description: Manually trigger an incremental RPG update (fallback for when the post-commit hook didn't run) +description: Explicitly run an AI-driven incremental RPG update in the foreground --- ## User Input @@ -14,22 +14,30 @@ proceed with default behavior. ## Outline -After every `git commit`, the **post-commit hook** automatically runs an -incremental RPG update in the background — so under normal use **you do -not need to run this command**. - -This slash command is a **manual fallback** for the few cases where the -automatic update didn't happen, e.g.: - -* You committed with `git commit --no-verify` (skipping hooks). -* The background hook errored out (network blip, LLM timeout) — run - `cmind version` to locate the workspace's logs directory and tail - the latest `update_rpg.log` there. -* You want to force a fresh update synchronously and see the result - immediately instead of waiting for the async hook. - -It compares the workspace against `HEAD~1` (same baseline the hook -uses) and runs the LLM-driven feature graph diff + dep_graph rebuild. +This command explicitly requests an LLM-driven feature graph diff and +dependency graph rebuild in the foreground. It is **not a Git hook +fallback**: Git hooks are OFF by default, and `--git-hooks` installs only +deterministic foreground sync, never AI calls or background workers. + +The comparison is the **current working tree against `HEAD~1`**. +Uncommitted changes are allowed; do not require a clean tree or commit/stash +the user's changes. `HEAD~1` is fixed, not the graph's last synced commit. +If the graph is stale across several commits or a branch switch, ask the +user whether a full `/cmind.encode` is more appropriate; do not rebuild +automatically. An existing usable RPG and locally available parent commit +are required. + +### Execution prerequisites and stop rule + +AI calls require a trusted explicit constructor choice, trusted process +environment selection, or valid user-local selection. Tracked +`recommended_provider` and valid legacy hints are not execution authority. +Normal provider approvals apply. On configuration, authentication, access, +or approval blocks at any step, surface the exact error and any reported +diagnostic artifact path, preserve artifacts, and pause for the user. +Do not retry, run init/update, rewrite configuration/local selection, or +grant trust/permission overrides to recover. Continue only after explicit +user resolution and authorization to retry. ### Step 1: Pre-Check @@ -41,29 +49,32 @@ cmind script rpg_encoder/check_encode.py --json Inspect the `type` field in the JSON output: -* **`error`** → display `message` and stop. The `rpg.json` file is - corrupt; the user may need to delete it and rerun `/cmind.encode`. -* **`init`** → no `rpg.json` yet. Tell the user to run `/cmind.encode` - first to create the baseline graph, then terminate. +* **`error`** → display the exact `message` and any reported artifact + path, preserve the graph/reports, and stop. Do not assume every error + means corruption or delete/rebuild the graph automatically; ask the user + how to proceed. +* **`init`** → no `rpg.json` yet. Explain that `/cmind.encode` can create + the baseline graph and ask the user to request it, then terminate. * **`update`** → display `result.stats.repo_name`, Feature graph `node_count` / `edge_count`, and Dependency graph `dep_nodes` / `dep_edges`, then proceed to Step 2. -Also verify there is at least one previous commit (the update needs -`HEAD~1` as baseline): +Also verify the parent commit is available locally (at least two commits; +shallow history may not contain the needed parent): ```bash -git rev-list --count HEAD +git rev-parse --verify HEAD~1 ``` -If the count is `< 2`, tell the user there is no previous commit to -diff against, and suggest running `/cmind.encode` instead. Terminate. +If this fails, show the exact error and explain that the required baseline +is unavailable. Suggest `/cmind.encode` as a user-chosen alternative and +stop; do not fetch history, make commits, or run encoding automatically. ### Step 2: Run the Update -Invoke the same script the post-commit hook uses. It creates and cleans -up its own temporary worktree internally — **you do not need to manage -`git worktree` manually**. +Explicitly invoke the AI update script, independently of Git hooks. It +creates and cleans up its own temporary worktree internally — **you do +not need to manage `git worktree` manually**. ```bash cmind script update_graphs.py update-rpg --json @@ -90,23 +101,26 @@ RPG update complete! Saved to: ``` -**If `status` is `"error"`**: +**If `status` is `"error"`, an `error` field is present, or the process exits +non-zero**: -* Show the `error` field. +* Show the exact `error` / stderr and any reported diagnostic artifact path. + Preserve the existing graph and reports; stop rather than claiming success. * Tell the user to run `cmind version` to locate the logs directory and inspect `update_rpg.log` for the full trace. -* Common causes: LLM API misconfigured, network failure, dirty worktree - blocking `git worktree add`. +* Configuration, authentication, executable/access, or provider approval + blocks follow the stop rule above, not an automatic retry or repair path. +* Other possible causes include network failures or errors creating the + temporary worktree. Uncommitted workspace changes alone are not a blocker. ### Step 4: Next Steps (optional) ```text Tips: - - The post-commit hook runs this same update automatically after - every commit; you only need to invoke this command when the - automatic update failed or was skipped. - - /cmind.encode — Run a full re-encode if the RPG seems stale or - has drifted significantly from the codebase. + - /cmind.update_rpg explicitly requests an AI update; Git sync + hooks and session startup do not request it for you. + - /cmind.encode — Choose a full re-encode if the graph needs a + new baseline; rebuilding requires the user's explicit decision. - The latest `update_rpg.log` (path shown by `cmind version`) keeps the most recent run output. ``` diff --git a/CoderMind/tests/run_security_tests.py b/CoderMind/tests/run_security_tests.py new file mode 100644 index 0000000..7318739 --- /dev/null +++ b/CoderMind/tests/run_security_tests.py @@ -0,0 +1,180 @@ +"""Run mocked security regressions without changing the developer's settings. + +Use an existing Python environment. By default no real child process is used; +--integration opts into inert local Git/encoder tests (never AI providers). +No dependency installation is performed. Test writes use a disposable directory under +CoderMind; home/config/temp variables are redirected only in this process. +This audit guard prevents accidental test side effects, not hostile Python code. +""" + +import os +from pathlib import Path +import shlex +import sys +import tempfile + + +class SandboxAudit: + """Guard test writes using each filesystem operation's symlink semantics. + + Content writes follow the final link; directory-entry operations resolve + only the parent. In particular, unlinking a cyclic or dangling test link + must not attempt to resolve the target of the link being removed. + """ + + def __init__(self, sandbox: Path) -> None: + self.sandbox = sandbox.resolve() + + def check_path(self, value, dir_fd=None, *, follow_leaf=True): + if isinstance(value, int): + return # Existing stdout/stderr and temporary file descriptors. + raw_path = os.fsdecode(value) + path = Path(raw_path) + if not path.is_absolute() and dir_fd is not None and dir_fd >= 0: + # Linux shutil.rmtree uses fd-relative paths during cleanup. + fd_path = Path(f"/proc/self/fd/{dir_fd}") + if not fd_path.exists(): + raise PermissionError("Cannot verify fd-relative test write") + path = fd_path.resolve() / path + # Trailing separators/dot components request directory traversal; Path + # normalizes those away, so preserve their semantics from the raw input. + spelling = raw_path.replace(os.altsep, os.sep) if os.altsep else raw_path + directory_operand = spelling.endswith((os.sep, os.sep + ".", os.sep + "..")) + if follow_leaf or directory_operand or path.name in ("", ".", ".."): + checked = path.resolve() + else: + checked = path.parent.resolve() / path.name + if not checked.is_relative_to(self.sandbox): + raise PermissionError("Security test attempted a write outside its sandbox") + + def __call__(self, event, args): + if event in ("subprocess.Popen", "os.system", "os.exec", "os.posix_spawn", + "os.spawn", "socket.connect", "socket.bind", "socket.getaddrinfo"): + raise PermissionError("Security tests forbid real processes and network access") + if event == "open": + _, mode, flags = args + if (mode and any(c in mode for c in "wax+")) or flags & ( + os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND + ): + self.check_path(args[0]) + elif event in ("os.remove", "os.rmdir"): + self.check_path(args[0], args[1], follow_leaf=False) + elif event == "os.mkdir": + self.check_path(args[0], args[2], follow_leaf=False) + elif event == "os.chmod": + self.check_path(args[0], args[2]) + elif event == "os.utime": + self.check_path(args[0], args[3]) + elif event == "os.truncate": + self.check_path(args[0]) + elif event == "os.rename": + self.check_path(args[0], args[2], follow_leaf=False) + self.check_path(args[1], args[3], follow_leaf=False) + elif event == "os.link": + # A hard link must not create an alias to external content. + self.check_path(args[0], args[2]) + self.check_path(args[1], args[3], follow_leaf=False) + elif event == "os.symlink": + self.check_path(args[1], args[2], follow_leaf=False) + + +class IntegrationAudit(SandboxAudit): + """Allow explicit external executables for our opt-in inert integration tests. + + Child processes are not Python-audit sandboxes. The selected tests use only + disposable workspaces/home and trusted local programs, never AI or payloads. + """ + + def __call__(self, event, args): + if event == "subprocess.Popen": + # On Windows CPython audits lpApplicationName=None and the quoted + # command line, not the original argv list supplied to Popen. + value = args[0] + if value is None: + command = args[1] + value = (shlex.split(command, posix=False)[0].strip('"') + if isinstance(command, str) else command[0]) + executable = Path(value) + if not executable.is_absolute() or executable.resolve().is_relative_to(self.sandbox): + raise PermissionError("Integration tests require an absolute external executable") + return + super().__call__(event, args) + + +def main() -> int: + sys.dont_write_bytecode = True + root = Path(__file__).resolve().parents[1] + original_cwd = Path.cwd() + with tempfile.TemporaryDirectory(prefix=".security-tests-", dir=root) as tmp: + sandbox = Path(tmp).resolve() + home = sandbox / "home" + home.mkdir() + # These changes affect this test process only, never the parent shell. + for key in list(os.environ): + if key.startswith(("CMIND_", "PYTEST_")): + del os.environ[key] + for key in ("HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", + "XDG_DATA_HOME", "XDG_CACHE_HOME", "TMP", "TEMP", "TMPDIR"): + os.environ[key] = str(home) + os.environ["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + # Installed mode preloads Rich before pytest captures output. Keep its + # formatting deterministic rather than asserting against ANSI escapes. + os.environ["TERM"] = "dumb" + os.environ["NO_COLOR"] = "1" + tempfile.tempdir = str(home) + os.chdir(sandbox) + + integration = "--integration" in sys.argv + sys.addaudithook(IntegrationAudit(sandbox) if integration else SandboxAudit(sandbox)) + try: + if "--installed" in sys.argv: + # CI-only mode after installing the freshly built wheel. Preload + # the modules so test sys.path helpers cannot mask a stale wheel. + import cmind_cli + from cmind_cli import _assets + + package = Path(cmind_cli.__file__).resolve().parent + if package.is_relative_to(root): + raise RuntimeError("Artifact tests require an external wheel installation") + for source_root, target_root in ( + (root / "src/cmind_cli", package), + (root / "scripts", _assets.scripts_dir()), + ): + for source in source_root.rglob("*.py"): + if "__pycache__" in source.parts: + continue + target = target_root / source.relative_to(source_root) + if source.read_bytes() != target.read_bytes(): + raise RuntimeError(f"Installed artifact does not match reviewed source: {source.name}") + sys.path.insert(0, str(_assets.scripts_dir())) + from common import ai_cli_policy, windows_ai_cli, llm_client, session_manager, trusted_tools + + for module in (ai_cli_policy, windows_ai_cli, llm_client, session_manager, trusted_tools): + if not Path(module.__file__).resolve().is_relative_to(package): + raise RuntimeError("Artifact test imported source instead of wheel") + print("Verified installed wheel sources and imported module origins") + else: + sys.path[:0] = [str(root / "src"), str(root / "scripts")] + + import pytest + + names = ( + ("test_initial_encode_prompt.py", "test_product_execution_integration.py") + if integration else + ("test_ai_cli_policy.py", "test_llm_client_agent_detect.py", "test_windows_ai_cli.py", + "test_hooks_install.py", "test_security_test_sandbox.py", "test_product_execution_security.py") + ) + return pytest.main([ + *[str(root / "tests" / name) for name in names], + "-k", "not update_graphs_status", + "--basetemp", str(sandbox / "pytest"), + "--log-file", str(sandbox / "pytest.log"), + "-p", "no:cacheprovider", "--assert=plain", "-q", + ]) + finally: + os.chdir(original_cwd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/CoderMind/tests/test_ai_cli_policy.py b/CoderMind/tests/test_ai_cli_policy.py new file mode 100644 index 0000000..7c5333e --- /dev/null +++ b/CoderMind/tests/test_ai_cli_policy.py @@ -0,0 +1,1113 @@ +"""Security regressions: all process execution is mocked, never run a payload.""" + +from contextlib import contextmanager +import hashlib +import importlib.util +import json +import os +from pathlib import Path, PureWindowsPath +import shutil +import subprocess +import sys +import tempfile +from unittest.mock import Mock +import zipfile + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "scripts")) + +# Pin the closed catalog independently so collection need not import common: +# its package initializer eagerly imports the home-reading LLM client. +PROVIDER_ARGV = { + "copilot": ("copilot",), + "claude": ("claude",), + "gemini": ("gemini", "-p"), + "qwen": ("qwen", "-p"), + "cursor-agent": ("agent", "-p"), + "auggie": ("augment", "-p"), + "codex": ("codex", "exec"), + "codebuddy": ("codebuddy", "-p"), + "qoder": ("qodercli", "-p"), + "opencode": ("opencode", "run"), + "amp": ("amp", "--execute"), +} +HINT_KEYS = ("recommended_provider", "ai_provider", "ai_cli_cmd") +RELEASE_MODULES = ( + "common/ai_cli_policy.py", "common/windows_ai_cli.py", + "common/llm_client.py", "common/session_manager.py", + "common/trusted_tools.py", "common/git_utils.py", + "common/generated_artifacts.py", "common/rpg_io.py", + "code_gen/git_ops.py", "rpg_encoder/version_control.py", + "rpg_encoder/run_encode.py", "update_graphs.py", "feature_build.py", + "future/nested/security_helper.py", +) + + +def write_config(root, contents): + path = root / ".cmind" / "config.toml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + return path + + +@pytest.fixture(autouse=True) +def isolated_workspace(tmp_path, monkeypatch): + # A unique sibling home is disposable but is NOT inside this workspace. + home = tmp_path.parent / (tmp_path.name + "-home") + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + monkeypatch.chdir(tmp_path) + for name in ("CMIND_AI_PROVIDER", "CMIND_AI_CLI_CMD", "CMIND_HOOK"): + monkeypatch.delenv(name, raising=False) + # An accidental real spawn fails even when these tests run without the runner. + spawn = Mock(side_effect=AssertionError("Real subprocess forbidden in policy tests")) + monkeypatch.setattr(subprocess, "Popen", spawn) + # Defer imports that derive home paths until isolation is installed. Keep + # CMIND_WORKSPACE untouched; clients use this explicit workspace finder. + from common import paths + + monkeypatch.setattr(paths, "_find_workspace_root", lambda: tmp_path) + return spawn + + +@pytest.fixture +def policy(isolated_workspace): + from common import ai_cli_policy + + return ai_cli_policy + + +@pytest.fixture +def llm_client(isolated_workspace, session_manager, monkeypatch): + from common import llm_client as module + + monkeypatch.setattr(module, "_BAKED_IN_VALUE", module._PLACEHOLDER_LITERAL) + return module + + +@pytest.fixture +def session_manager(isolated_workspace, tmp_path, monkeypatch): + from common import session_manager as module + + monkeypatch.setattr(module, "COPILOT_LOGS_DIR", tmp_path / "logs" / "copilot") + monkeypatch.setattr(module.ClaudeSessionManager, "DEFAULT_TRAJECTORY_DIR", + tmp_path / "logs" / "claude") + return module + + +def selection_record(workspace, provider="claude"): + return { + "schema_version": 1, + "workspace": os.path.normcase(str(workspace.resolve())), + "ai_provider": provider, + } + + +def expected_selection_path(workspace): + # Compute the contract independently, without policy's private helpers. + identity = os.path.normcase(str(workspace.resolve())) + digest = hashlib.sha256(identity.encode("utf-8")).hexdigest() + return Path.home().resolve() / ".cmind" / "execution" / digest / "selection.json" + + +@pytest.fixture +def local_selection(tmp_path, policy): + write_config(tmp_path, '[cmind]\nrecommended_provider = "copilot"\n') + policy.write_local_provider(tmp_path, "claude") + return expected_selection_path(tmp_path) + + +def assert_invalid_local_rejected(policy, workspace): + path = expected_selection_path(workspace) + before = path.read_bytes() + with pytest.raises(policy.AICommandPolicyError): + policy.read_local_provider(workspace) + for baked in ("", "claude", "codex exec"): + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(workspace, environ={}, baked=baked) + assert path.read_bytes() == before + + +def symlink_or_skip(link, target, *, directory=False): + try: + link.symlink_to(target, target_is_directory=directory) + except (OSError, NotImplementedError): + pytest.skip("OS does not allow symlink creation") + + +def test_provider_catalog_is_closed(policy): + assert policy.PROVIDER_ARGV == PROVIDER_ARGV + + +@pytest.mark.parametrize("ordinary,extended", [ + (r"C:\Users\developer\workspace", r"\\?\C:\Users\developer\workspace"), + (r"\\server\share\workspace", r"\\?\UNC\server\share\workspace"), +]) +def test_extended_windows_spelling_shares_identity_and_boundary(policy, ordinary, extended): + plain = PureWindowsPath(ordinary) + long = PureWindowsPath(extended) + assert policy._comparison_path(long) == plain + assert policy._is_within(long / "nested", plain) + assert policy._is_within(plain / "nested", long) + assert policy._workspace_identity(Mock(resolve=lambda: plain)) == policy._workspace_identity(Mock(resolve=lambda: long)) + + + +@pytest.mark.parametrize("hint_key", HINT_KEYS) +@pytest.mark.parametrize("provider,argv", PROVIDER_ARGV.items()) +def test_closed_provider_and_exact_legacy_mapping(tmp_path, policy, provider, argv, hint_key): + assert policy.validate_provider(provider) == provider + command = " ".join(argv) + assert policy.provider_from_command(command) == provider + hint = command if hint_key == "ai_cli_cmd" else provider + write_config(tmp_path, f'[cmind]\n{hint_key} = "{hint}"\n') + assert policy.read_workspace_provider(tmp_path) == provider + assert policy.read_local_provider(tmp_path) == "" + assert policy.resolve_provider(tmp_path, environ={}) == "" + assert policy.resolve_provider(tmp_path, environ={}, baked=command) == "" + assert not (Path.home() / ".cmind").exists() + + +INVALID_COMMANDS = [ + "", " ", "unknown", "python -c PLACEHOLDER", "sh -c PLACEHOLDER", + "claude --dangerously-skip-permissions", "copilot --allow-all", + "claude --settings config.json", "claude; ignored", "claude\nignored", + '"claude"', " claude", "claude ", "CLAUDE", "codex exec", + "./claude", "/tmp/claude", r"C:\tools\claude.exe", "claude.cmd", + "claude.exe", "claude.ps1", None, 1, [], {}, True, +] + + +@pytest.mark.parametrize("value", INVALID_COMMANDS) +def test_reject_noncanonical_commands(policy, value): + with pytest.raises(policy.AICommandPolicyError): + policy.provider_from_command(value) + + +@pytest.mark.parametrize("contents", [ + "[cmind", "cmind = 7", '[cmind]\nai_provider = "unknown"', + '[cmind]\nai_provider = "claude -p"', '[cmind]\nai_provider = ""', + '[cmind]\nai_provider = 7', '[cmind]\nai_cli_cmd = []', + '[cmind]\nai_cli_cmd = "claude --settings file"', + '[cmind]\nai_provider = "claude"\nai_cli_cmd = "claude"', + '[cmind]\nrecommended_provider = "unknown"', + '[cmind]\nrecommended_provider = "codex exec"', + '[cmind]\nrecommended_provider = ""', + '[cmind]\nrecommended_provider = 7', + '[cmind]\nrecommended_provider = true', + '[cmind]\nrecommended_provider = []', + '[cmind]\nrecommended_provider = {}', + '[cmind]\nrecommended_provider = "claude"\nai_provider = "claude"', + '[cmind]\nrecommended_provider = "claude"\nai_cli_cmd = "claude"', + '[cmind]\nrecommended_provider = "claude"\nai_provider = "claude"\nai_cli_cmd = "claude"', +]) +def test_invalid_workspace_never_falls_back(tmp_path, policy, contents, isolated_workspace, llm_client): + write_config(tmp_path, contents) + policy.write_local_provider(tmp_path, "claude") + with pytest.raises(policy.AICommandPolicyError): + policy.read_workspace_provider(tmp_path) + # Invalid recommendations fail even when CI explicitly chooses the same + # documented provider, or a constructor/local selection already authorizes it. + for overrides in ( + {"environ": {}}, + {"environ": {}, "tool": "claude"}, + {"environ": {"CMIND_AI_PROVIDER": "claude"}}, + {"environ": {"CMIND_AI_CLI_CMD": "claude"}}, + ): + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, baked="claude", **overrides) + with pytest.raises(policy.AICommandPolicyError): + llm_client.LLMClient() + isolated_workspace.assert_not_called() + + +@pytest.mark.parametrize("source", ["tool", "env", "provider_env", "baked"]) +def test_each_command_source_fails_closed(tmp_path, policy, source): + policy.write_local_provider(tmp_path, "copilot") + write_config(tmp_path, '[cmind]\nrecommended_provider = "claude"') + kwargs = {"environ": {}} + if source == "tool": + kwargs["tool"] = "claude extra" + elif source == "env": + kwargs["environ"] = {"CMIND_AI_CLI_CMD": "claude extra"} + kwargs["baked"] = "claude" + elif source == "provider_env": + kwargs["environ"] = {"CMIND_AI_PROVIDER": "claude extra"} + kwargs["baked"] = "claude" + else: + kwargs["baked"] = "claude extra" + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, **kwargs) + + +def test_precedence_and_empty_vs_absent(tmp_path, policy): + assert policy.resolve_provider(tmp_path, environ={}) == "" + assert policy.resolve_provider(tmp_path, environ={}, baked="codex exec") == "" + write_config(tmp_path, '[cmind]\nrecommended_provider = "claude"') + assert policy.resolve_provider(tmp_path, environ={}) == "" + policy.write_local_provider(tmp_path, "qwen") + assert policy.resolve_provider(tmp_path, environ={}) == "qwen" + assert policy.resolve_provider(tmp_path, environ={}, baked="codex exec") == "qwen" + env = {"CMIND_AI_PROVIDER": "copilot"} + assert policy.resolve_provider(tmp_path, environ=env) == "copilot" + assert policy.resolve_provider(tmp_path, environ=env, tool="codex exec") == "codex" + assert policy.resolve_provider(tmp_path, environ={"CMIND_AI_CLI_CMD": "codex exec"}) == "codex" + for env in ({"CMIND_AI_PROVIDER": ""}, {"CMIND_AI_CLI_CMD": ""}, + {"CMIND_AI_PROVIDER": "claude", "CMIND_AI_CLI_CMD": "claude"}): + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, environ=env) + assert policy.read_local_provider(tmp_path) == "qwen" + + +@pytest.mark.parametrize("provider,argv", PROVIDER_ARGV.items()) +def test_explicit_and_environment_authority_uses_only_closed_values(tmp_path, policy, provider, argv): + write_config(tmp_path, '[cmind]\nrecommended_provider = "claude"') + policy.write_local_provider(tmp_path, "copilot") + command = " ".join(argv) + assert policy.resolve_provider(tmp_path, environ={}, tool=command) == provider + assert policy.resolve_provider(tmp_path, environ={"CMIND_AI_PROVIDER": provider}) == provider + assert policy.resolve_provider(tmp_path, environ={"CMIND_AI_CLI_CMD": command}) == provider + assert policy.read_local_provider(tmp_path) == "copilot" + + +@pytest.mark.parametrize("overrides", [ + {"environ": {}, "tool": "codex exec"}, + {"environ": {"CMIND_AI_PROVIDER": "copilot"}, "tool": "codex exec"}, + {"environ": {"CMIND_AI_CLI_CMD": "copilot"}, "tool": "codex exec"}, + {"environ": {"CMIND_AI_PROVIDER": "codex"}}, + {"environ": {"CMIND_AI_CLI_CMD": "codex exec"}}, +]) +def test_higher_authority_does_not_read_or_rewrite_local_selection( + tmp_path, monkeypatch, policy, local_selection, overrides, +): + before = local_selection.read_bytes() + read_local = Mock(side_effect=AssertionError("Higher-priority authority must not read local state")) + monkeypatch.setattr(policy, "read_local_provider", read_local) + assert policy.resolve_provider(tmp_path, baked="claude", **overrides) == "codex" + read_local.assert_not_called() + assert local_selection.read_bytes() == before + + +@pytest.mark.parametrize("name", ["CMIND_AI_PROVIDER", "CMIND_AI_CLI_CMD"]) +@pytest.mark.parametrize("value", INVALID_COMMANDS) +def test_invalid_environment_cannot_fall_back_to_local(tmp_path, policy, local_selection, name, value): + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, environ={name: value}, baked="claude") + assert policy.read_local_provider(tmp_path) == "claude" + + +@pytest.mark.parametrize("command", [ + " ".join(argv) for argv in PROVIDER_ARGV.values() + if " ".join(argv) not in PROVIDER_ARGV +]) +def test_provider_environment_does_not_accept_legacy_commands(tmp_path, policy, command): + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, environ={"CMIND_AI_PROVIDER": command}) + + +@pytest.mark.parametrize("hint_key", HINT_KEYS) +@pytest.mark.parametrize("operation", ["clone", "move"]) +def test_repository_hint_in_clone_or_move_does_not_authorize(tmp_path, policy, hint_key, operation): + original = tmp_path / "original" + hint = "codex exec" if hint_key == "ai_cli_cmd" else "codex" + config = write_config(original, f'[cmind]\n{hint_key} = "{hint}"\n') + config_bytes = config.read_bytes() + policy.write_local_provider(original, "copilot") + selection = expected_selection_path(original) + selection_bytes = selection.read_bytes() + assert policy.resolve_provider(original, environ={}) == "copilot" + + destination = tmp_path / operation + if operation == "clone": + shutil.copytree(original, destination) + else: + original.rename(destination) + + assert (destination / ".cmind" / "config.toml").read_bytes() == config_bytes + assert policy.read_workspace_provider(destination) == "codex" + assert policy.read_local_provider(destination) == "" + assert policy.resolve_provider(destination, environ={}, baked="codex exec") == "" + assert not expected_selection_path(destination).exists() + assert selection.read_bytes() == selection_bytes + + +@pytest.mark.parametrize("provider", PROVIDER_ARGV) +def test_local_selection_uses_full_canonical_hash_and_strict_record(tmp_path, policy, provider): + config = write_config(tmp_path, '[cmind]\nrecommended_provider = "claude"\n') + config_bytes = config.read_bytes() + policy.write_local_provider(tmp_path, provider) + path = policy.local_selection_path(tmp_path) + assert path == expected_selection_path(tmp_path) + assert len(path.parent.name) == 64 + assert not path.resolve().is_relative_to(tmp_path.resolve()) + record = json.loads(path.read_text(encoding="utf-8")) + assert record == selection_record(tmp_path, provider) + assert type(record["schema_version"]) is int + assert policy.read_local_provider(tmp_path) == provider + assert policy.resolve_provider(tmp_path, environ={}, baked="codex exec") == provider + assert config.read_bytes() == config_bytes + assert set(path.parent.iterdir()) == {path} + + +def test_local_selection_is_separate_from_copyable_storage_metadata(tmp_path, policy): + from cmind_cli import _storage + + metadata = _storage.workspace_meta_path(tmp_path) + metadata.parent.mkdir(parents=True) + metadata.write_text('ai_provider = "copilot"\n', encoding="utf-8") + metadata_bytes = metadata.read_bytes() + write_config(tmp_path, '[cmind]\nrecommended_provider = "copilot"\n') + assert policy.resolve_provider(tmp_path, environ={}, baked="copilot") == "" + policy.write_local_provider(tmp_path, "claude") + selection = policy.local_selection_path(tmp_path) + assert not selection.is_relative_to(metadata.parent) + assert metadata.read_bytes() == metadata_bytes + assert policy.read_local_provider(tmp_path) == "claude" + + +@pytest.mark.parametrize("contents", [ + b"", b"{", b'{"schema_version":', b"\xff", b"null", b"[]", b'"claude"', b"1", b"true", +]) +def test_malformed_local_selection_never_falls_back(tmp_path, policy, local_selection, contents): + local_selection.write_bytes(contents) + assert_invalid_local_rejected(policy, tmp_path) + + +@pytest.mark.parametrize("field", ["schema_version", "workspace", "ai_provider"]) +def test_local_selection_requires_every_field(tmp_path, policy, local_selection, field): + record = selection_record(tmp_path) + del record[field] + local_selection.write_text(json.dumps(record), encoding="utf-8") + assert_invalid_local_rejected(policy, tmp_path) + + +@pytest.mark.parametrize("field,value", [ + ("schema_version", 0), ("schema_version", 2), ("schema_version", True), + ("schema_version", 1.0), ("schema_version", "1"), ("schema_version", None), + ("workspace", ""), ("workspace", "different-workspace"), + ("workspace", None), ("workspace", 1), ("workspace", []), + ("extra", "not allowed"), +]) +def test_local_selection_requires_exact_schema_and_identity(tmp_path, policy, local_selection, field, value): + record = selection_record(tmp_path) + record[field] = value + local_selection.write_text(json.dumps(record), encoding="utf-8") + assert_invalid_local_rejected(policy, tmp_path) + + +@pytest.mark.parametrize("provider", [*INVALID_COMMANDS, "codex exec", "agent -p"]) +def test_invalid_local_provider_never_falls_back(tmp_path, policy, local_selection, provider): + local_selection.write_text(json.dumps(selection_record(tmp_path, provider)), encoding="utf-8") + assert_invalid_local_rejected(policy, tmp_path) + + +def test_copied_local_selection_cannot_authorize_another_workspace(tmp_path, policy): + original = tmp_path / "original" + copied = tmp_path / "copied" + for workspace in (original, copied): + write_config(workspace, '[cmind]\nrecommended_provider = "claude"\n') + policy.write_local_provider(original, "claude") + source = expected_selection_path(original) + destination = expected_selection_path(copied) + assert source != destination + destination.parent.mkdir(parents=True) + destination.write_bytes(source.read_bytes()) + assert_invalid_local_rejected(policy, copied) + assert policy.read_local_provider(original) == "claude" + + +@pytest.mark.parametrize("existing_provider", [None, "claude"]) +def test_atomic_replace_failure_preserves_selection_and_removes_temporary_files( + tmp_path, monkeypatch, policy, existing_provider, +): + if existing_provider is not None: + policy.write_local_provider(tmp_path, existing_provider) + selection = expected_selection_path(tmp_path) + before = selection.read_bytes() if selection.exists() else None + + def fail_replace(source, destination): + source = Path(source) + assert Path(destination) == selection + assert source != selection + assert source.parent == selection.parent + assert json.loads(source.read_text(encoding="utf-8")) == selection_record(tmp_path, "copilot") + assert (selection.read_bytes() if selection.exists() else None) == before + raise OSError("simulated atomic replace failure") + + replace = Mock(side_effect=fail_replace) + monkeypatch.setattr(policy.os, "replace", replace) + with pytest.raises(OSError, match="simulated atomic replace failure"): + policy.write_local_provider(tmp_path, "copilot") + replace.assert_called_once() + assert (selection.read_bytes() if selection.exists() else None) == before + assert set(selection.parent.iterdir()) == ({selection} if before is not None else set()) + assert policy.read_local_provider(tmp_path) == (existing_provider or "") + + +@pytest.mark.parametrize("provider", [*INVALID_COMMANDS, "codex exec", "agent -p"]) +def test_invalid_write_provider_creates_no_directories(tmp_path, policy, provider): + root = Path.home() / ".cmind" + assert not root.exists() + with pytest.raises(policy.AICommandPolicyError): + policy.write_local_provider(tmp_path, provider) + assert not root.exists() + assert not (tmp_path / ".cmind").exists() + + +@pytest.mark.parametrize("component", ["home-store", "execution", "workspace", "selection"]) +@pytest.mark.parametrize("dangling", [False, True]) +def test_local_selection_symlinks_and_directory_redirection_are_rejected( + tmp_path, policy, component, dangling, +): + selection = expected_selection_path(tmp_path) + link = { + "home-store": selection.parents[2], + "execution": selection.parents[1], + "workspace": selection.parent, + "selection": selection, + }[component] + target_root = Path.home().resolve() / "redirect-target" + directory = component != "selection" + target = target_root if directory else target_root / "selection.json" + target_selection = target / selection.relative_to(link) if directory else target + record = json.dumps(selection_record(tmp_path)).encode("utf-8") + if not dangling: + target_selection.parent.mkdir(parents=True) + target_selection.write_bytes(record) + link.parent.mkdir(parents=True, exist_ok=True) + symlink_or_skip(link, target, directory=directory) + + with pytest.raises(policy.AICommandPolicyError): + policy.local_selection_path(tmp_path) + with pytest.raises(policy.AICommandPolicyError): + policy.read_local_provider(tmp_path) + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, environ={}, baked="claude") + with pytest.raises(policy.AICommandPolicyError): + policy.write_local_provider(tmp_path, "copilot") + if dangling: + assert not target_root.exists() + else: + assert target_selection.read_bytes() == record + assert not list(target_root.rglob(".selection-*.tmp")) + + +def test_selection_directory_is_invalid_not_missing(tmp_path, policy): + selection = expected_selection_path(tmp_path) + selection.mkdir(parents=True) + write_config(tmp_path, '[cmind]\nrecommended_provider = "claude"\n') + with pytest.raises(policy.AICommandPolicyError): + policy.read_local_provider(tmp_path) + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, environ={}, baked="claude") + + +def test_symlinked_workspace_reuses_canonical_local_selection(tmp_path, policy): + workspace = tmp_path / "workspace" + write_config(workspace, '[cmind]\nrecommended_provider = "codex"\n') + alias = tmp_path / "alias" + symlink_or_skip(alias, workspace, directory=True) + policy.write_local_provider(workspace, "claude") + selection = expected_selection_path(workspace) + assert policy.local_selection_path(alias) == selection + assert policy.read_local_provider(alias) == "claude" + assert policy.resolve_provider(alias, environ={}) == "claude" + policy.write_local_provider(alias, "copilot") + assert policy.read_local_provider(workspace) == "copilot" + assert json.loads(selection.read_text(encoding="utf-8")) == selection_record(workspace, "copilot") + assert list((Path.home() / ".cmind" / "execution").glob("*/selection.json")) == [selection] + + +@pytest.mark.parametrize("nested_home", [False, True]) +def test_user_local_store_inside_workspace_is_rejected(tmp_path, monkeypatch, policy, nested_home): + home = tmp_path / "user-home" if nested_home else tmp_path + home.mkdir(exist_ok=True) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + write_config(tmp_path, '[cmind]\nrecommended_provider = "claude"\n') + with pytest.raises(policy.AICommandPolicyError): + policy.local_selection_path(tmp_path) + with pytest.raises(policy.AICommandPolicyError): + policy.read_local_provider(tmp_path) + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, environ={}, baked="claude") + with pytest.raises(policy.AICommandPolicyError): + policy.write_local_provider(tmp_path, "claude") + assert not (home / ".cmind" / "execution").exists() + + +def make_cli(directory, name="claude"): + directory.mkdir(parents=True, exist_ok=True) + path = directory / (name + ".exe" if os.name == "nt" else name) + path.write_bytes(b"not an executable; test fixture only") + path.chmod(0o755) + return path.resolve() + + +@pytest.mark.parametrize("provider,base", PROVIDER_ARGV.items()) +def test_exact_internal_argv_and_absolute_executable(tmp_path, monkeypatch, policy, provider, base): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.chdir(workspace) + executable = make_cli(tmp_path / "trusted tools", base[0]) + assert policy.build_argv(provider, workspace, search_path=str(executable.parent)) == [ + str(executable), *base[1:], + ] + + +def test_path_search_ignores_workspace_current_and_relative_entries(tmp_path, monkeypatch, policy): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.chdir(workspace) + make_cli(workspace) + make_cli(workspace / "bin") + trusted = make_cli(tmp_path / "trusted") + path = os.pathsep.join(["", ".", "bin", str(workspace), str(workspace / "bin"), str(trusted.parent)]) + assert policy.build_argv("claude", workspace, search_path=path) == [str(trusted)] + with pytest.raises(policy.AICommandPolicyError): + policy.build_argv("claude", workspace, search_path=os.pathsep.join(["", ".", str(workspace)])) + + +def test_symlink_to_workspace_executable_is_rejected(tmp_path, monkeypatch, policy): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.chdir(workspace) + local = make_cli(workspace) + trusted = tmp_path / "trusted" + trusted.mkdir() + link = trusted / local.name + try: + link.symlink_to(local) + except OSError: + pytest.skip("OS does not allow symlink creation") + with pytest.raises(policy.AICommandPolicyError): + policy.build_argv("claude", workspace, search_path=str(trusted)) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows wrapper policy") +def test_windows_wrappers_never_selected(tmp_path, monkeypatch, policy): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.chdir(workspace) + tools = tmp_path / "tools" + tools.mkdir() + for suffix in (".cmd", ".bat", ".ps1"): + (tools / ("claude" + suffix)).write_text("not executable") + with pytest.raises(policy.AICommandPolicyError): + policy.build_argv("claude", workspace, search_path=str(tools)) + + +@pytest.mark.parametrize("value", [v for v in INVALID_COMMANDS if v is not None]) +def test_invalid_constructor_cannot_spawn(value, policy, isolated_workspace, llm_client): + with pytest.raises(policy.AICommandPolicyError): + llm_client.LLMClient(tool=value) + isolated_workspace.assert_not_called() + + +@pytest.mark.parametrize("hook", ["post-commit", "post-merge", "pre-commit"]) +def test_hook_context_blocks_ai_before_trace(monkeypatch, policy, isolated_workspace, llm_client, hook): + client = llm_client.LLMClient(tool="claude") + trace = Mock(side_effect=AssertionError("Trace must not start")) + monkeypatch.setattr(client._session_manager, "trace", trace) + monkeypatch.setenv("CMIND_HOOK", hook) + with pytest.raises(policy.AICommandPolicyError, match="hooks is disabled"): + client.generate("test") + trace.assert_not_called() + isolated_workspace.assert_not_called() + + +@pytest.mark.parametrize("replacement", ["copilot", None]) +def test_local_change_between_construction_and_generate_is_rejected( + tmp_path, monkeypatch, policy, isolated_workspace, llm_client, local_selection, replacement, +): + client = llm_client.LLMClient() + assert client.tool == "claude" + trace = Mock(side_effect=AssertionError("Trace must not start")) + monkeypatch.setattr(client._session_manager, "trace", trace) + if replacement is None: + local_selection.unlink() + else: + policy.write_local_provider(tmp_path, replacement) + with pytest.raises(policy.AICommandPolicyError, match="changed"): + client.generate("test", max_retries=5) + trace.assert_not_called() + isolated_workspace.assert_not_called() + + +@pytest.mark.parametrize("stage", ["construction", "generation"]) +def test_invalid_local_client_never_falls_back_to_hint_or_baked( + tmp_path, monkeypatch, policy, isolated_workspace, llm_client, local_selection, stage, +): + monkeypatch.setattr(llm_client, "_BAKED_IN_VALUE", "codex exec") + client = llm_client.LLMClient() if stage == "generation" else None + trace = Mock(side_effect=AssertionError("Trace must not start")) + if client is not None: + assert client.tool == "claude" + monkeypatch.setattr(client._session_manager, "trace", trace) + local_selection.write_text('{"ai_provider": "claude"}', encoding="utf-8") + with pytest.raises(policy.AICommandPolicyError): + if client is None: + llm_client.LLMClient() + else: + client.generate("test", max_retries=5) + assert_invalid_local_rejected(policy, tmp_path) + trace.assert_not_called() + isolated_workspace.assert_not_called() + + +def test_unknown_mutated_tool_is_rejected_before_trace(monkeypatch, policy, isolated_workspace, llm_client): + client = llm_client.LLMClient(tool="claude") + client.tool = "unknown command" + trace = Mock(side_effect=AssertionError("Trace must not start")) + monkeypatch.setattr(client._session_manager, "trace", trace) + with pytest.raises(policy.AICommandPolicyError): + client.generate("test") + trace.assert_not_called() + isolated_workspace.assert_not_called() + + +@pytest.mark.parametrize("hint_key", HINT_KEYS) +@pytest.mark.parametrize("authority", ["constructor", "local"]) +def test_mocked_generation_uses_absolute_internal_argv_despite_hint_changes( + tmp_path, monkeypatch, policy, llm_client, session_manager, hint_key, authority, +): + config = write_config(tmp_path, f'[cmind]\n{hint_key} = "claude"\n') + policy.write_local_provider(tmp_path, "codex") + selection = expected_selection_path(tmp_path) + selection_bytes = selection.read_bytes() + client = llm_client.LLMClient(tool="codex exec" if authority == "constructor" else None) + config.write_text(f'[cmind]\n{hint_key} = "copilot"\n', encoding="utf-8") + assert policy.read_workspace_provider(tmp_path) == "copilot" + assert policy.resolve_provider(tmp_path, environ={}) == "codex" + assert client.tool == "codex exec" + argv = [str(tmp_path / "trusted" / "codex"), "exec"] + monkeypatch.setattr(llm_client, "build_argv", lambda *a: argv) + + @contextmanager + def trace(*args, **kwargs): + ctx = session_manager.TraceContext() + ctx.extra_args = ["--test-internal-flag"] + yield ctx + + monkeypatch.setattr(client._session_manager, "trace", trace) + proc = Mock(returncode=0) + proc.communicate.return_value = ("test response", "") + spawn = Mock(return_value=proc) + monkeypatch.setattr(llm_client.subprocess, "Popen", spawn) + assert client.generate("test prompt") == "test response" + assert spawn.call_args.args[0] == [*argv, "--test-internal-flag"] + assert spawn.call_args.kwargs["shell"] is False + assert spawn.call_args.kwargs["cwd"] == tmp_path.resolve() + spawn.assert_called_once() + assert policy.read_local_provider(tmp_path) == "codex" + assert selection.read_bytes() == selection_bytes + + +@pytest.mark.parametrize("provider,package,entry", [ + ("claude", "@anthropic-ai/claude-code", "bin/claude.exe"), + ("claude", "@anthropic-ai/claude-code", "cli.js"), + ("copilot", "@github/copilot", "index.js"), + ("copilot", "@github/copilot", "npm-loader.js"), +]) +def test_npm_generation_preserves_prompt_as_data( + tmp_path, monkeypatch, policy, llm_client, provider, package, entry, +): + tools = Path.home() / "trusted npm 工具" + installed = tools / "node_modules" / package + script = installed / entry + script.parent.mkdir(parents=True) + script.write_bytes(b"INERT FIXTURE ONLY") + (installed / "package.json").write_text(json.dumps({ + "name": package, "bin": {provider: entry}, + })) + node = tools / "node.exe" + node.write_bytes(b"INERT FIXTURE ONLY") + monkeypatch.setattr(policy, "_IS_WINDOWS", True) + monkeypatch.setenv("PATH", str(tools)) + policy.write_local_provider(tmp_path, provider) + write_config(tmp_path, '[cmind]\nrecommended_provider = "codex"\n') + client = llm_client.LLMClient() + prompt = 'Read literally: "quotes" & | %VALUE% !VALUE! ^ $()\n第二行' + captured = {} + + def popen(argv, **kwargs): + captured["argv"] = argv + captured["kwargs"] = kwargs + if kwargs["stdin"] is not None: + captured["stdin"] = kwargs["stdin"].read() + proc = Mock(returncode=0) + proc.communicate.return_value = ("mocked response", "") + return proc + + spawn = Mock(side_effect=popen) + monkeypatch.setattr(llm_client.subprocess, "Popen", spawn) + assert client.generate(prompt) == "mocked response" + prefix = [str(script.resolve())] if entry.endswith(".exe") else [str(node.resolve()), str(script.resolve())] + assert captured["argv"][:len(prefix)] == prefix + assert captured["kwargs"]["shell"] is False + assert captured["kwargs"]["cwd"] == tmp_path.resolve() + if provider == "copilot": + assert captured["argv"][-2:] == ["-p", prompt] + else: + assert captured["stdin"] == prompt + assert "--allow-all" not in captured["argv"] + assert "--dangerously-skip-permissions" not in captured["argv"] + spawn.assert_called_once() + assert policy.read_local_provider(tmp_path) == provider + + +@pytest.mark.parametrize("command,agent", [ + ("claude --flag", "claude"), + ('"C:\\tools\\claude.cmd" --flag', "claude"), + ("C:\\tools\\Gemini.EXE --flag", "gemini"), +]) +def test_upstream_agent_identification_does_not_authorize_execution( + command, agent, policy, llm_client, isolated_workspace, +): + assert llm_client.detect_agent_type(command) == agent + with pytest.raises(policy.AICommandPolicyError): + llm_client.LLMClient(tool=command) + isolated_workspace.assert_not_called() + + +@pytest.mark.parametrize("windows", [False, True]) +@pytest.mark.parametrize("explicit_encoding", [False, True]) +def test_transplant_preserves_upstream_utf8_and_platform_launch( + tmp_path, monkeypatch, llm_client, session_manager, windows, explicit_encoding, +): + client = llm_client.LLMClient(tool="codex exec") + argv = [str(tmp_path / "external" / "codex.exe"), "exec"] + monkeypatch.setattr(llm_client, "build_argv", lambda *a: argv) + monkeypatch.setattr(llm_client, "_IS_WINDOWS", windows) + monkeypatch.setattr(llm_client.subprocess, "CREATE_NEW_PROCESS_GROUP", 0x200, raising=False) + child_env = {"UNCHANGED": "yes"} + if explicit_encoding: + child_env.update(PYTHONIOENCODING="custom-encoding", PYTHONUTF8="0") + + @contextmanager + def trace(*args, **kwargs): + ctx = session_manager.TraceContext() + ctx.env = child_env + yield ctx + + monkeypatch.setattr(client._session_manager, "trace", trace) + proc = Mock(returncode=0) + proc.communicate.return_value = ("response 中文 →", "") + spawn = Mock(return_value=proc) + monkeypatch.setattr(llm_client.subprocess, "Popen", spawn) + before = child_env.copy() + assert client.generate("prompt 中文 →") == "response 中文 →" + kwargs = spawn.call_args.kwargs + assert spawn.call_args.args[0] == argv + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" + assert kwargs["shell"] is False + assert kwargs["cwd"] == tmp_path.resolve() + assert child_env == before + if windows: + assert kwargs["creationflags"] == 0x200 + assert "preexec_fn" not in kwargs and "start_new_session" not in kwargs + assert kwargs["env"]["PYTHONIOENCODING"] == ("custom-encoding" if explicit_encoding else "utf-8:replace") + assert kwargs["env"]["PYTHONUTF8"] == ("0" if explicit_encoding else "1") + else: + assert kwargs["start_new_session"] is True + assert kwargs["preexec_fn"] is llm_client._set_pdeathsig + assert "creationflags" not in kwargs + assert kwargs["env"] == before + + +@pytest.mark.parametrize("outcome", ["success", "nonzero", "os-error"]) +def test_transplant_preserves_windows_tree_cleanup(monkeypatch, llm_client, outcome): + monkeypatch.setattr(llm_client, "_IS_WINDOWS", True) + proc = Mock(pid=12345) + runner = Mock(return_value=Mock(returncode=0 if outcome == "success" else 1)) + if outcome == "os-error": + runner.side_effect = OSError("simulated taskkill failure") + monkeypatch.setattr(llm_client.subprocess, "run", runner) + llm_client._kill_process_tree(proc) + runner.assert_called_once_with( + ["taskkill", "/PID", "12345", "/T", "/F"], capture_output=True, check=False, + ) + if outcome == "success": + proc.kill.assert_not_called() + else: + proc.kill.assert_called_once() + + +def test_transplant_timeout_uses_upstream_tree_cleanup(monkeypatch, llm_client): + client = llm_client.LLMClient(tool="codex exec") + monkeypatch.setattr(llm_client, "build_argv", lambda *a: ["trusted-codex", "exec"]) + proc = Mock(returncode=0) + proc.communicate.side_effect = subprocess.TimeoutExpired("trusted-codex", 1) + monkeypatch.setattr(llm_client.subprocess, "Popen", Mock(return_value=proc)) + cleanup = Mock() + monkeypatch.setattr(llm_client, "_kill_process_tree", cleanup) + with pytest.raises(RuntimeError, match="timed out"): + client.generate("prompt", max_retries=1, timeout=1) + cleanup.assert_called_once_with(proc) + proc.wait.assert_called_once() + + +def test_missing_executable_is_not_retried(monkeypatch, policy, isolated_workspace, llm_client): + client = llm_client.LLMClient(tool="claude") + monkeypatch.setenv("PATH", "") + with pytest.raises(policy.AICommandPolicyError, match="No trusted"): + client.generate("test", max_retries=5) + isolated_workspace.assert_not_called() + + +@pytest.mark.parametrize("agent", ["claude", "copilot"]) +def test_session_managers_do_not_bypass_permissions(tmp_path, session_manager, agent): + # The fixture isolates home BEFORE the manager constructor inspects it. + assert not Path.home().resolve().is_relative_to(tmp_path.resolve()) + manager = session_manager.create_session_manager(agent, project_dir=tmp_path) + with manager.trace("test") as ctx: + assert "--dangerously-skip-permissions" not in ctx.extra_args + assert "--allow-all" not in ctx.extra_args + assert "-p" in ctx.extra_args + + +def test_release_baked_commands_obey_the_same_policy(tmp_path, policy): + # Historical release substitutions are validated hints, not local choices. + for argv in policy.PROVIDER_ARGV.values(): + command = " ".join(argv) + assert policy.resolve_provider(tmp_path, environ={}, baked=command) == "" + policy.write_local_provider(tmp_path, "copilot") + for argv in policy.PROVIDER_ARGV.values(): + command = " ".join(argv) + assert policy.resolve_provider(tmp_path, environ={}, baked=command) == "copilot" + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, environ={}, baked="untrusted --argument") + + +@pytest.mark.parametrize("overrides", [ + {"environ": {}}, + {"environ": {}, "tool": "claude"}, + {"environ": {"CMIND_AI_PROVIDER": "claude"}}, + {"environ": {"CMIND_AI_CLI_CMD": "claude"}}, +]) +@pytest.mark.parametrize("baked", ["untrusted --argument", "codex", "claude ", " claude"]) +def test_invalid_baked_hint_is_validated_even_with_authority(tmp_path, policy, local_selection, overrides, baked): + with pytest.raises(policy.AICommandPolicyError): + policy.resolve_provider(tmp_path, baked=baked, **overrides) + + +@pytest.mark.parametrize("hint_key", [None, *HINT_KEYS]) +@pytest.mark.parametrize("baked", ["", "codex exec"]) +def test_unconfigured_client_is_lazy_but_hints_never_authorize_a_launch( + tmp_path, monkeypatch, isolated_workspace, llm_client, hint_key, baked, +): + if hint_key is not None: + write_config(tmp_path, f'[cmind]\n{hint_key} = "claude"\n') + monkeypatch.setattr(llm_client, "_BAKED_IN_VALUE", baked) + client = llm_client.LLMClient() + assert client.tool == "" + trace = Mock(side_effect=AssertionError("Trace must not start")) + build = Mock(side_effect=AssertionError("Executable resolution must not start")) + monkeypatch.setattr(client._session_manager, "trace", trace) + monkeypatch.setattr(llm_client, "build_argv", build) + with pytest.raises(RuntimeError, match="not configured"): + client.generate("test", max_retries=5) + trace.assert_not_called() + build.assert_not_called() + isolated_workspace.assert_not_called() + assert not (Path.home() / ".cmind").exists() + + +@pytest.fixture +def release_verifier(): + spec = importlib.util.spec_from_file_location("verify_release", ROOT / "tests/verify_release_security.py") + assert spec is not None and spec.loader is not None + verifier = importlib.util.module_from_spec(spec) + spec.loader.exec_module(verifier) + return verifier + + +@pytest.fixture +def release_packages(tmp_path, monkeypatch, release_verifier): + # Small synthetic scripts keep the full 22-ZIP matrix cheap. Do not execute + # production packagers or depend on the size of real pipeline modules. + sources = { + name: f'# {name}\nBAKED = ""\n' + 'raise AssertionError("Packaged scripts must not be executed")\n' + for name in RELEASE_MODULES + } + monkeypatch.setattr(release_verifier, "expected_script_sources", lambda root: sources) + # Use the independently pinned provider catalog and literal script types; + # a verifier that silently narrows either catalog must fail these fixtures. + for provider, argv in PROVIDER_ARGV.items(): + for script_type in ("sh", "ps"): + archive = tmp_path / f"cmind-template-{provider}-{script_type}-v0.0.0-test.zip" + with zipfile.ZipFile(archive, "w") as stream: + stream.writestr(".cmind/scripts/", b"") + stream.writestr(".cmind/config.toml", b"[cmind]\n") + for name, source in sources.items(): + text = source.replace("", " ".join(argv)) + if script_type == "ps": + text = "\ufeff" + text.replace("\n", "\r\n") + stream.writestr(f".cmind/scripts/{name}", text) + monkeypatch.setattr(sys, "argv", ["verify_release_security.py", str(tmp_path)]) + return sources + + +def _rewrite_release_fixture(path, *, omit=None, replacements=None, extra=()): + with zipfile.ZipFile(path) as archive: + members = [(info.filename, archive.read(info)) for info in archive.infolist()] + with zipfile.ZipFile(path, "w") as archive: + for name, data in members: + if name != omit: + archive.writestr(name, (replacements or {}).get(name, data)) + for name, data in extra: + archive.writestr(name, data) + + +def test_release_verifier_checks_all_22_variants_read_only( + tmp_path, monkeypatch, capsys, release_verifier, release_packages, +): + before = {path: path.read_bytes() for path in tmp_path.glob("*.zip")} + assert len(before) == 22 + parse = Mock(wraps=release_verifier.ast.parse) + monkeypatch.setattr(release_verifier.ast, "parse", parse) + release_verifier.main() + assert "all 22 release ZIPs" in capsys.readouterr().out + assert {path: path.read_bytes() for path in tmp_path.glob("*.zip")} == before + assert parse.call_count == 22 * len(release_packages) + assert {call.kwargs["filename"] for call in parse.call_args_list} == { + f"{path.name}:.cmind/scripts/{name}" + for path in before for name in release_packages + } + + +@pytest.mark.parametrize("script_type", ["sh", "ps"]) +@pytest.mark.parametrize("corrupt", ["stale", "missing", "duplicate"]) +@pytest.mark.parametrize("module", RELEASE_MODULES) +def test_release_verifier_detects_stale_missing_or_duplicate_assets( + tmp_path, release_verifier, release_packages, script_type, corrupt, module, +): + path = tmp_path / f"cmind-template-claude-{script_type}-v0.0.0-test.zip" + name = f".cmind/scripts/{module}" + if corrupt == "stale": + # Still valid Python: AST validity alone is not source identity. + _rewrite_release_fixture(path, replacements={name: b"# stale artifact\n"}) + message = "stale or altered" + elif corrupt == "missing": + _rewrite_release_fixture(path, omit=name) + message = "catalog mismatch" + else: + with zipfile.ZipFile(path) as archive: + duplicate = archive.read(name) + with pytest.warns(UserWarning, match="Duplicate name"): + _rewrite_release_fixture(path, extra=[(name, duplicate)]) + message = "Duplicate ZIP members" + with pytest.raises(RuntimeError, match=message) as error: + release_verifier.main() + assert path.name in str(error.value) + assert name in str(error.value) + + +@pytest.mark.parametrize("script_type", ["sh", "ps"]) +@pytest.mark.parametrize("corrupt", ["missing", "duplicate", "balanced"]) +def test_release_verifier_requires_exactly_one_zip_per_pair( + tmp_path, release_verifier, release_packages, script_type, corrupt, +): + path = tmp_path / f"cmind-template-claude-{script_type}-v0.0.0-test.zip" + if corrupt == "missing": + path.unlink() + else: + shutil.copyfile(path, tmp_path / f"cmind-template-claude-{script_type}-v0.0.0-old.zip") + if corrupt == "balanced": + # Counting 22 archives alone must not hide a duplicate + missing pair. + (tmp_path / f"cmind-template-amp-{script_type}-v0.0.0-test.zip").unlink() + assert len(list(tmp_path.glob("*.zip"))) == 22 + with pytest.raises(RuntimeError, match=f"exactly one {script_type} release ZIP for claude"): + release_verifier.main() + + +@pytest.mark.parametrize("name", [ + "cmind-template-unknown-sh-v0.0.0-test.zip", + "cmind-template-claude-other-v0.0.0-test.zip", + "unrelated.zip", + "unexpected.ZIP", +]) +def test_release_verifier_rejects_extra_archives(tmp_path, release_verifier, release_packages, name): + with zipfile.ZipFile(tmp_path / name, "w"): + pass + with pytest.raises(RuntimeError, match="exactly 22 release ZIPs"): + release_verifier.main() + + +@pytest.mark.parametrize("script_type", ["sh", "ps"]) +@pytest.mark.parametrize("name", [".cmind/scripts/", ".cmind/config.toml"]) +def test_release_verifier_rejects_duplicate_non_python_members( + tmp_path, release_verifier, release_packages, script_type, name, +): + path = tmp_path / f"cmind-template-claude-{script_type}-v0.0.0-test.zip" + with pytest.warns(UserWarning, match="Duplicate name"): + _rewrite_release_fixture(path, extra=[(name, b"")]) + with pytest.raises(RuntimeError, match="Duplicate ZIP members"): + release_verifier.main() + + +@pytest.mark.parametrize("script_type", ["sh", "ps"]) +def test_release_verifier_rejects_obsolete_extra_modules( + tmp_path, release_verifier, release_packages, script_type, +): + path = tmp_path / f"cmind-template-claude-{script_type}-v0.0.0-test.zip" + name = ".cmind/scripts/common/removed_module.py" + _rewrite_release_fixture(path, extra=[(name, b"# obsolete module\n")]) + with pytest.raises(RuntimeError, match="catalog mismatch") as error: + release_verifier.main() + assert name in str(error.value) + + +@pytest.mark.parametrize("script_type", ["sh", "ps"]) +@pytest.mark.parametrize("corrupt", ["syntax", "encoding", "substitution"]) +def test_release_verifier_validates_packaged_python( + tmp_path, release_verifier, release_packages, script_type, corrupt, +): + path = tmp_path / f"cmind-template-claude-{script_type}-v0.0.0-test.zip" + module = "common/trusted_tools.py" + sources = dict(release_packages) + if corrupt == "syntax": + # Even byte-identical source must parse; checking only identity is insufficient. + sources[module] = "def invalid(:\n" + data = sources[module].encode("utf-8") + message = "Invalid Python script" + elif corrupt == "encoding": + data = b"\xff" + message = "Invalid Python script" + else: + data = sources[module].replace("", "copilot").encode("utf-8") + message = "stale or altered" + _rewrite_release_fixture(path, replacements={f".cmind/scripts/{module}": data}) + with pytest.raises(RuntimeError, match=message): + release_verifier.verify_archive(path, sources, "claude") + + +def test_release_source_catalog_is_recursive_and_excludes_only_caches(tmp_path, release_verifier): + scripts = tmp_path / "scripts" + expected = { + "__init__.py": "", + "common/trusted_tools.py": 'BAKED = ""\n', + "new/deep/pipeline.py": "# new pipeline helper\n", + } + ignored = { + "__pycache__/cached.py": "invalid cached Python", + "new/__pycache__/deep/cached.py": "invalid nested cached Python", + "common/readme.txt": "not Python", + } + for name, text in {**expected, **ignored}.items(): + path = scripts / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(("\ufeff" + text.replace("\n", "\r\n")).encode("utf-8")) + (scripts / "directory.py").mkdir() + assert release_verifier.expected_script_sources(scripts) == expected + + +def test_release_source_catalog_cannot_be_empty(tmp_path, release_verifier): + with pytest.raises(RuntimeError, match="No source Python scripts"): + release_verifier.expected_script_sources(tmp_path) +# All release variants must retain the reviewed Python script catalog. diff --git a/CoderMind/tests/test_hooks_install.py b/CoderMind/tests/test_hooks_install.py index 85d0af8..a4ba90d 100644 --- a/CoderMind/tests/test_hooks_install.py +++ b/CoderMind/tests/test_hooks_install.py @@ -7,9 +7,12 @@ - ``_install_copilot_hooks`` writes a VS Code task with ``runOptions.runOn = "folderOpen"``, is idempotent, and preserves pre-existing user tasks. - - ``_install_hooks`` dispatches the right AI-specific installer, - installs git post-commit/post-merge dispatchers, and removes - CoderMind-owned pre-commit blocks when a ``.git`` dir exists. + - ``_install_hooks`` installs status integrations, removes only cmind + Git blocks by default, and installs sync-only dispatchers on opt-in. + - Workspace provider configuration is validated before provisioning or + self-upgrade, without importing policy code from the workspace. + - Repository recommendations never grant execution consent; explicit local + choices are saved only after hooks succeed, in an isolated fake home. - ``update_graphs.py status`` returns RPG/dep-graph stats + an agent-facing MCP-tools reminder, on both populated and empty workspaces. @@ -17,11 +20,15 @@ import json import os +import shlex import subprocess import sys from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import Mock import pytest +from typer.testing import CliRunner # Ensure src/ and scripts/ are importable _project_root = Path(__file__).resolve().parent.parent @@ -36,9 +43,24 @@ # Fixtures # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def isolated_home(tmp_path, monkeypatch): + """Keep real local-selection writes outside both the project and real HOME.""" + home = tmp_path.parent / f"{tmp_path.name}-home" + home.mkdir() + home = home.resolve() + assert not home.is_relative_to(tmp_path.resolve()) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + return home + + @pytest.fixture -def project(tmp_path): +def project(tmp_path, monkeypatch): """A minimal CoderMind workspace with .cmind/scripts/update_graphs.py.""" + # Never follow a developer's global core.hooksPath out of tmp_path. + monkeypatch.setattr(cmind_cli, "_read_core_hooks_path", lambda _: None) scripts_dir = tmp_path / ".cmind" / "scripts" scripts_dir.mkdir(parents=True) # The installers only need the file to exist; we copy the real script @@ -51,6 +73,62 @@ def project(tmp_path): return tmp_path +@pytest.fixture(params=[ + "Python with spaces", + "Python's $(echo unused); & `echo unused` %PATH%!", +]) +def alternate_python(isolated_home, request): + """Absolute placeholder interpreter for serialization tests; never executed.""" + executable = isolated_home / request.param / "python.exe" + executable.parent.mkdir() + executable.touch() + return executable.resolve() + + +@pytest.fixture +def trusted_git(isolated_home, tmp_path, monkeypatch): + """Pin a fake external Git without consulting the host's installed tools.""" + executable = isolated_home / "tools" / "git.exe" + executable.parent.mkdir() + executable.touch() + executable = executable.resolve() + assert executable.is_absolute() + assert not executable.is_relative_to(tmp_path.resolve()) + monkeypatch.setattr(cmind_cli, "_resolve_git", Mock(return_value=str(executable))) + return executable + + +def _expected_cli_argv(*args): + """Independently pin the interpreter and this installation's bootstrap.""" + entry = Path(cmind_cli.__file__).resolve().with_name("_cli_entry.py") + assert Path(sys.executable).is_absolute() + assert entry.is_absolute() and entry.is_file() + return [sys.executable, "-I", str(entry), *args] + + +def _assert_pinned_shell_command(command, *args): + argv = _expected_cli_argv(*args) + # Git Bash/Claude need POSIX path spelling, not Windows backslashes. + argv[0] = Path(argv[0]).as_posix() + argv[2] = Path(argv[2]).as_posix() + assert command == shlex.join(argv) + assert shlex.split(command) == argv + + +def _assert_pinned_git_hook(text, name): + begin = f"# CMIND-BEGIN {name}" + end = f"# CMIND-END {name}" + assert text.count(begin) == 1 + assert text.count(end) == 1 + body = text.split(begin + "\n", 1)[1].split(end, 1)[0].splitlines() + assert len(body) == 2 + assert body[0] == f"# CoderMind: {name} dispatcher" + suffix = " 2>/dev/null || true" + assert body[1].endswith(suffix) + _assert_pinned_shell_command(body[1].removesuffix(suffix), "hook", name) + assert cmind_cli._HOOK_PATH_FALLBACK not in text + + # --------------------------------------------------------------------------- # Claude hook # --------------------------------------------------------------------------- @@ -62,27 +140,32 @@ def test_install_claude_hooks_writes_session_start(project): session_start = data["hooks"]["SessionStart"] assert isinstance(session_start, list) and len(session_start) == 1 cmd = session_start[0]["hooks"][0]["command"] - # Hook now invokes the global ``cmind`` CLI; no embedded sys.executable. - assert "cmind script update_graphs.py status" in cmd - # PATH fallback for GUI-launched session starts (VS Code / IDE git UI). - assert "command -v cmind" in cmd - assert cmd.endswith("status 2>/dev/null || echo '[CoderMind] RPG status unavailable'") + # Pin the interpreter/bootstrap rather than resolving cmind through PATH. + suffix = " 2>/dev/null || echo '[CoderMind] RPG status unavailable'" + assert cmd.endswith(suffix) + _assert_pinned_shell_command( + cmd.removesuffix(suffix), "script", "update_graphs.py", "status", + ) + assert cmind_cli._HOOK_PATH_FALLBACK not in cmd -def test_install_claude_hooks_is_idempotent_across_python_upgrades(project, monkeypatch): - """Re-installing must not stack duplicate SessionStart entries. +def test_install_claude_hooks_is_idempotent_across_python_upgrades( + project, monkeypatch, alternate_python, no_subprocess, +): + """Replace a stale interpreter pin once, without stacking SessionStart entries.""" + cmind_cli._install_claude_hooks(project) + settings = project / ".claude" / "settings.json" + original = settings.read_bytes() + cmind_cli._install_claude_hooks(project) + assert settings.read_bytes() == original - Hooks no longer embed ``sys.executable``; they delegate to the - globally-installed ``cmind`` CLI. Re-running install therefore - yields the exact same command and must remain a single entry - (not a duplicate per invocation). - """ + monkeypatch.setattr(cmind_cli.sys, "executable", str(alternate_python)) cmind_cli._install_claude_hooks(project) - # Simulate an environment change; the hook body is - # interpreter-independent so this should be a no-op. - monkeypatch.setattr(cmind_cli.sys, "executable", "/opt/new-python/bin/python") + upgraded = settings.read_bytes() + assert upgraded != original cmind_cli._install_claude_hooks(project) - data = json.loads((project / ".claude" / "settings.json").read_text()) + assert settings.read_bytes() == upgraded + data = json.loads(upgraded) session_start = data["hooks"]["SessionStart"] cmind_entries = [ e for e in session_start @@ -90,28 +173,32 @@ def test_install_claude_hooks_is_idempotent_across_python_upgrades(project, monk ] assert len(cmind_entries) == 1 cmd = cmind_entries[0]["hooks"][0]["command"] - # Always uses the cmind-script form regardless of interpreter path. - assert "cmind script update_graphs.py" in cmd - assert "/opt/new-python/bin/python" not in cmd - + suffix = " 2>/dev/null || echo '[CoderMind] RPG status unavailable'" + assert cmd.endswith(suffix) + _assert_pinned_shell_command( + cmd.removesuffix(suffix), "script", "update_graphs.py", "status", + ) + no_subprocess.assert_not_called() -def test_install_claude_hooks_shell_escapes_special_chars(project, monkeypatch): - """Interpreter / workspace paths must not appear in the hook command. - The hook body invokes the global ``cmind`` CLI directly, so paths - with special characters cannot end up inside the command string. - """ - monkeypatch.setattr( - cmind_cli.sys, "executable", "/path with space/python" - ) +def test_install_claude_hooks_shell_escapes_special_chars( + project, monkeypatch, alternate_python, no_subprocess, +): + """Spaces and shell metacharacters stay literal within the interpreter token.""" + monkeypatch.setattr(cmind_cli.sys, "executable", str(alternate_python)) cmind_cli._install_claude_hooks(project) cmd = ( json.loads((project / ".claude" / "settings.json").read_text()) ["hooks"]["SessionStart"][0]["hooks"][0]["command"] ) - # No path leakage from the interpreter / workspace location. - assert "/path with space" not in cmd - assert "cmind script update_graphs.py" in cmd + suffix = " 2>/dev/null || echo '[CoderMind] RPG status unavailable'" + assert cmd.endswith(suffix) + _assert_pinned_shell_command( + cmd.removesuffix(suffix), "script", "update_graphs.py", "status", + ) + assert shlex.split(cmd.removesuffix(suffix))[0] == alternate_python.as_posix() + assert cmind_cli._HOOK_PATH_FALLBACK not in cmd + no_subprocess.assert_not_called() def test_install_claude_hooks_merges_existing(project): @@ -150,12 +237,12 @@ def test_install_copilot_hooks_writes_folder_open_task(project): t = tasks["tasks"][0] assert t["label"] == "CoderMind: load status" assert t["runOptions"] == {"runOn": "folderOpen"} - # Task now invokes the global ``cmind`` CLI; args carry the - # dispatcher subcommand + script relpath, with ``status`` last. - assert t["command"] == "cmind" - assert t["args"][0] == "script" - assert t["args"][1] == "update_graphs.py" - assert t["args"][-1] == "status" + # Process tasks preserve native paths as argv, with no shell parsing. + expected = cmind_cli._cli_argv("script", "update_graphs.py", "status") + assert expected == _expected_cli_argv("script", "update_graphs.py", "status") + assert t["type"] == "process" + assert t["command"] == expected[0] + assert t["args"] == expected[1:] # Status output should appear silently — we don't want it stealing focus. assert t["presentation"]["reveal"] == "silent" # NOTE: .gitignore management was moved to `_setup_gitignore` (called @@ -163,12 +250,32 @@ def test_install_copilot_hooks_writes_folder_open_task(project): # .gitignore. See test_setup_gitignore_* for ignore-rule coverage. -def test_install_copilot_hooks_is_idempotent(project): +def test_install_copilot_hooks_is_idempotent( + project, monkeypatch, alternate_python, no_subprocess, +): + """Repeated installs replace the interpreter pin, not the task's identity.""" cmind_cli._install_copilot_hooks(project) + tasks_path = project / ".vscode" / "tasks.json" + original = tasks_path.read_bytes() cmind_cli._install_copilot_hooks(project) - tasks = json.loads((project / ".vscode" / "tasks.json").read_text()) + assert tasks_path.read_bytes() == original + + monkeypatch.setattr(cmind_cli.sys, "executable", str(alternate_python)) + cmind_cli._install_copilot_hooks(project) + upgraded = tasks_path.read_bytes() + assert upgraded != original + cmind_cli._install_copilot_hooks(project) + assert tasks_path.read_bytes() == upgraded + tasks = json.loads(upgraded) labels = [t["label"] for t in tasks["tasks"]] assert labels.count("CoderMind: load status") == 1 + task = next(t for t in tasks["tasks"] if t["label"] == "CoderMind: load status") + expected = cmind_cli._cli_argv("script", "update_graphs.py", "status") + assert expected == _expected_cli_argv("script", "update_graphs.py", "status") + assert task["type"] == "process" + assert task["command"] == str(alternate_python) + assert [task["command"], *task["args"]] == expected + no_subprocess.assert_not_called() def test_install_copilot_hooks_preserves_user_tasks(project): @@ -194,7 +301,7 @@ def test_install_copilot_hooks_preserves_user_tasks(project): def test_install_hooks_dispatches_to_copilot(project, monkeypatch): (project / ".git" / "hooks").mkdir(parents=True) - cmind_cli._install_hooks(project, "copilot", tracker=None) + cmind_cli._install_hooks(project, "copilot", tracker=None, git_hooks=True) # Copilot tasks.json present, Claude settings.json absent. assert (project / ".vscode" / "tasks.json").is_file() @@ -203,22 +310,24 @@ def test_install_hooks_dispatches_to_copilot(project, monkeypatch): post_commit = (hooks_dir / "post-commit").read_text() post_merge = (hooks_dir / "post-merge").read_text() assert "CoderMind: post-commit dispatcher" in post_commit - assert "cmind hook post-commit" in post_commit + _assert_pinned_git_hook(post_commit, "post-commit") assert "CoderMind: post-merge dispatcher" in post_merge - assert "cmind hook post-merge" in post_merge + _assert_pinned_git_hook(post_merge, "post-merge") assert not (hooks_dir / "pre-commit").exists() def test_install_hooks_dispatches_to_claude(project): (project / ".git" / "hooks").mkdir(parents=True) - cmind_cli._install_hooks(project, "claude", tracker=None) + cmind_cli._install_hooks(project, "claude", tracker=None, git_hooks=True) assert (project / ".claude" / "settings.json").is_file() assert not (project / ".vscode" / "tasks.json").exists() hooks_dir = project / ".git" / "hooks" assert (hooks_dir / "post-commit").is_file() assert (hooks_dir / "post-merge").is_file() + for name in ("post-commit", "post-merge"): + _assert_pinned_git_hook((hooks_dir / name).read_text(), name) assert not (hooks_dir / "pre-commit").exists() @@ -229,11 +338,9 @@ def test_update_command_invokes_install_hooks(): gitignore, and MCP config refreshes, so existing workspaces receive hook dispatcher fixes when users run ``cmind update``. - This is a static-source assertion rather than an end-to-end test - because ``update`` does network I/O (template download) that is - too heavyweight to mock for a single-bit regression check. The - intent is simply: if someone deletes the ``_install_hooks(...)`` - call from ``update``, this test fails loudly. + This static assertion complements the mocked CLI tests below: + deleting the ``_install_hooks(...)`` call must fail loudly without + running provisioning or the optional CLI self-upgrade. """ import inspect source = inspect.getsource(cmind_cli.update) @@ -248,6 +355,52 @@ def test_update_command_invokes_install_hooks(): ) +@pytest.fixture +def no_subprocess(monkeypatch): + """Policy/installation tests must never launch processes or use the network.""" + blocked = Mock(side_effect=lambda *a, **kw: pytest.fail("Unexpected process or network call")) + for name in ("run", "Popen", "call", "check_call", "check_output"): + monkeypatch.setattr(cmind_cli.subprocess, name, blocked) + for name in ("execv", "execvp"): + monkeypatch.setattr(cmind_cli.os, name, blocked) + monkeypatch.setattr(cmind_cli.client, "request", blocked) + return blocked + + +@pytest.mark.parametrize("command", ["init", "update"]) +@pytest.mark.parametrize("flag,expected", [(None, False), ("--no-git-hooks", False), ("--git-hooks", True)]) +def test_cli_passes_explicit_git_hook_choice(tmp_path, monkeypatch, no_subprocess, command, flag, expected): + """Only the CLI flag enables hooks; all provisioning is stubbed locally.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".cmind").mkdir() + monkeypatch.setattr(cmind_cli, "show_banner", lambda: None) + for name in ( + "download_and_extract_template", "_setup_gitignore", + "ensure_cmind_runtime_dirs", "_maybe_offer_initial_encode", + ): + monkeypatch.setattr(cmind_cli, name, Mock()) + monkeypatch.setattr(cmind_cli, "_detect_install_method", lambda: "editable") + monkeypatch.setattr(cmind_cli, "_install_source", lambda: "editable") + monkeypatch.setattr(cmind_cli.shutil, "which", lambda _: "/installed/cmind") + installer = Mock() + monkeypatch.setattr(cmind_cli, "_install_hooks", installer) + args = [command, "--ai", "copilot", "--script", "sh", "--no-mcp", "--no-cmind-git"] + if command == "init": + args += ["--here", "--force", "--no-git", "--no-encode", "--ignore-agent-tools"] + else: + args += ["--no-upgrade"] + if flag: + args.append(flag) + + result = CliRunner().invoke(cmind_cli.app, args) + + assert result.exit_code == 0, result.output + installer.assert_called_once() + assert installer.call_args.kwargs["git_hooks"] is expected + assert cmind_cli._ai_cli_policy().read_local_provider(tmp_path) == "copilot" + no_subprocess.assert_not_called() + + # --------------------------------------------------------------------------- # Sentinel-block upgrade migration # --------------------------------------------------------------------------- @@ -292,7 +445,7 @@ def test_post_commit_v1_legacy_is_replaced_on_upgrade(project): assert text.count("# CMIND-BEGIN post-commit") == 1 assert text.count("# CMIND-END post-commit") == 1 assert "CoderMind: post-commit dispatcher" in text - assert "cmind hook post-commit" in text + _assert_pinned_git_hook(text, "post-commit") def test_post_commit_v3_legacy_is_replaced_on_upgrade(project): @@ -318,21 +471,32 @@ def test_post_commit_v3_legacy_is_replaced_on_upgrade(project): assert text.count("# CMIND-BEGIN post-commit") == 1 assert text.count("# CMIND-END post-commit") == 1 assert text.count("# CoderMind: post-commit dispatcher") == 1 - assert "cmind hook post-commit" in text + _assert_pinned_git_hook(text, "post-commit") -def test_install_is_idempotent_under_sentinels(project): - """Repeated dispatcher installs must not stack sentinel blocks.""" +@pytest.mark.parametrize("name", ["post-commit", "post-merge"]) +def test_install_is_idempotent_under_sentinels( + project, monkeypatch, alternate_python, no_subprocess, name, +): + """Interpreter upgrades replace one quoted dispatcher block per hook.""" hd = _hooks_dir(project) - cmind_cli._install_git_post_commit_hook(project) - first = (hd / "post-commit").read_text() - cmind_cli._install_git_post_commit_hook(project) - cmind_cli._install_git_post_commit_hook(project) - third = (hd / "post-commit").read_text() - + install = getattr(cmind_cli, f"_install_git_{name.replace('-', '_')}_hook") + install(project) + first = (hd / name).read_text() + install(project) + install(project) + third = (hd / name).read_text() assert first == third - assert third.count("# CMIND-BEGIN post-commit") == 1 - assert third.count("# CMIND-END post-commit") == 1 + _assert_pinned_git_hook(third, name) + + monkeypatch.setattr(cmind_cli.sys, "executable", str(alternate_python)) + install(project) + upgraded = (hd / name).read_text() + assert upgraded != first + install(project) + assert (hd / name).read_text() == upgraded + _assert_pinned_git_hook(upgraded, name) + no_subprocess.assert_not_called() def test_sentinel_block_is_atomically_replaceable(project): @@ -354,7 +518,7 @@ def test_sentinel_block_is_atomically_replaceable(project): assert "--legacy-flag" not in text assert text.count("# CMIND-BEGIN post-commit") == 1 assert text.count("# CMIND-END post-commit") == 1 - assert "cmind hook post-commit" in text + _assert_pinned_git_hook(text, "post-commit") def test_user_authored_content_outside_block_is_preserved(project): @@ -380,6 +544,934 @@ def test_user_authored_content_outside_block_is_preserved(project): assert "# CMIND-END pre-commit" not in text +@pytest.mark.parametrize("selected_ai", ["claude", "copilot"]) +def test_default_removes_all_cmind_blocks_preserving_user_bytes(project, selected_ai, no_subprocess): + hd = _hooks_dir(project) + before = b"#!/bin/sh\r\necho 'user before'\r\n" + after = b"echo 'user after'\r\n\r\n" + for name in ("pre-commit", "post-commit", "post-merge"): + block = ( + f"# CMIND-BEGIN {name}\r\n" + "cmind script update_graphs.py update-rpg --json &\r\n" + f"# CMIND-END {name}\r\n" + ).encode() + (hd / name).write_bytes(before + block + after) + + cmind_cli._install_hooks(project, selected_ai) + cmind_cli._install_hooks(project, selected_ai) + + for name in ("pre-commit", "post-commit", "post-merge"): + assert (hd / name).read_bytes() == before + after + if selected_ai == "claude": + assert (project / ".claude" / "settings.json").is_file() + else: + assert (project / ".vscode" / "tasks.json").is_file() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("name,body", [ + ("pre-commit", "# CoderMind: full RPG sync on commit\n/old/python /old/update_graphs.py sync 2>/dev/null || true\n"), + ("pre-commit", "# CoderMind: incremental RPG sync on commit\ncmind script update_graphs.py sync --staged-only\n"), + ("post-commit", "# CoderMind: advance meta.git after commit\n/old/python /old/update_graphs.py sync 2>/dev/null || true\n"), + ("post-commit", ( + "# CoderMind: advance meta.git + background feature graph update\n" + "/old/python /old/update_graphs.py sync 2>/dev/null || true\n" + "if [ ! -f /old/.lock ]; then\n" + ' setsid env -u GIT_INDEX_FILE -u GIT_DIR sh -c "cd /old; sleep 2; touch /old/.lock; ' + '/old/python /old/update_graphs.py update-rpg --json >> /old/log 2>&1; ' + 'rm -f /old/.lock" /dev/null 2>&1 &\n' + "fi\n" + )), + ("post-merge", "# CoderMind: incremental RPG sync after merge / pull\ncmind script update_graphs.py sync\n"), + ("post-commit", ( + "# CoderMind: advance meta.git + background feature graph update\n" + "'/old python/python3.12' '/old scripts/update_graphs.py' sync 2>/dev/null || true\n" + "if [ ! -f '/old logs/.lock' ]; then\n" + ' setsid sh -c "\'/old python/python3.12\' \'/old scripts/update_graphs.py\' ' + 'update-rpg --json" /dev/null 2>&1 &\n' + "fi\n" + )), + *[(name, f"# CoderMind: {name} dispatcher\n{cmind_cli._HOOK_PATH_FALLBACK}\ncmind hook {name} 2>/dev/null || true\n") + for name in ("pre-commit", "post-commit", "post-merge")], +]) +def test_default_removes_legacy_bodies_without_consuming_user_lines(project, no_subprocess, name, body): + hd = _hooks_dir(project) + before = "#!/bin/sh\necho user-before\n" + after = "echo user-after\necho still-user\n" + (hd / name).write_bytes((before + body + after).encode()) + + cmind_cli._install_hooks(project, "copilot") + + assert (hd / name).read_bytes() == (before + after).encode() + no_subprocess.assert_not_called() + + +def test_default_deletes_owned_only_hooks(project, no_subprocess): + hd = _hooks_dir(project) + for name in ("pre-commit", "post-commit", "post-merge"): + (hd / name).write_text( + f"#!/bin/sh\n# CMIND-BEGIN {name}\ncmind hook {name}\n# CMIND-END {name}\n" + ) + cmind_cli._install_hooks(project, "copilot") + assert not any((hd / name).exists() for name in ("pre-commit", "post-commit", "post-merge")) + + +@pytest.mark.parametrize("body", [ + b"", b"#!/bin/sh\n", b"#!/bin/sh\r\necho user\r\n", + b"#!/bin/sh\n# CMIND-BEGIN post-commit\necho keep-unmatched-tail\n", + b"#!/bin/sh\n# CoderMind: advance meta.git after commit\necho not-a-cmind-body\n", + b"#!/bin/sh\n# user mentions # CoderMind: advance meta.git after commit\necho user\n", +]) +def test_default_leaves_unowned_or_ambiguous_hooks_untouched(project, no_subprocess, body): + hd = _hooks_dir(project) + path = hd / "post-commit" + path.write_bytes(body) + original_mode = path.stat().st_mode + cmind_cli._install_hooks(project, "copilot") + assert path.read_bytes() == body + assert path.stat().st_mode == original_mode + + +def test_default_does_not_create_git_hooks_directory(project, no_subprocess): + (project / ".git").mkdir() + cmind_cli._install_hooks(project, "copilot") + assert not (project / ".git" / "hooks").exists() + + +def test_default_cleans_git_hooks_even_if_status_integration_fails(project, monkeypatch, no_subprocess): + hd = _hooks_dir(project) + path = hd / "post-commit" + path.write_text( + "#!/bin/sh\n# CMIND-BEGIN post-commit\n" + "cmind script update_graphs.py update-rpg &\n# CMIND-END post-commit\n" + ) + monkeypatch.setattr(cmind_cli, "_install_copilot_hooks", Mock(side_effect=OSError("read-only tasks"))) + cmind_cli._install_hooks(project, "copilot") + assert not path.exists() + + +@pytest.fixture +def legacy_hooks(project, no_subprocess): + """Owned hooks, including the old AI worker, confined to tmp_path.""" + hd = _hooks_dir(project) + bodies = { + "pre-commit": ( + "# CoderMind: full RPG sync on commit\n" + "cmind script update_graphs.py sync\n" + ), + "post-commit": ( + "# CoderMind: advance meta.git + background feature graph update\n" + "cmind script update_graphs.py sync\n" + "if [ ! -f .cmind-update.lock ]; then\n" + "setsid cmind script update_graphs.py update-rpg --json >/dev/null 2>&1 &\n" + "fi\n" + ), + "post-merge": ( + "# CoderMind: incremental RPG sync after merge / pull\n" + "cmind script update_graphs.py sync\n" + ), + } + for name, body in bodies.items(): + (hd / name).write_bytes(("#!/bin/sh\n" + body).encode("utf-8")) + return hd + + +@pytest.mark.parametrize("git_hooks", [False, True]) +@pytest.mark.parametrize("with_tracker", [False, True]) +def test_invalid_utf8_hook_preserved_while_other_legacy_hooks_are_cleaned( + project, legacy_hooks, monkeypatch, no_subprocess, git_hooks, with_tracker, +): + path = legacy_hooks / "pre-commit" + original = path.read_bytes() + b"# user bytes: \xff\r\n" + path.write_bytes(original) + original_mode = path.stat().st_mode + cleanup = Mock(wraps=cmind_cli._uninstall_git_hook) + monkeypatch.setattr(cmind_cli, "_uninstall_git_hook", cleanup) + blocked = Mock() + for name in ( + "_install_git_post_commit_hook", "_install_git_post_merge_hook", + "_install_copilot_hooks", "_install_claude_hooks", + ): + monkeypatch.setattr(cmind_cli, name, blocked) + tracker = Mock() if with_tracker else None + + with pytest.raises(RuntimeError) as exc: + cmind_cli._install_hooks(project, "copilot", tracker=tracker, git_hooks=git_hooks) + + assert str(exc.value) == cmind_cli._GIT_HOOK_CLEANUP_ERROR + assert [call.args[1] for call in cleanup.call_args_list] == [ + "pre-commit", "post-commit", "post-merge", + ] + assert path.read_bytes() == original + assert path.stat().st_mode == original_mode + assert not (legacy_hooks / "post-commit").exists() + assert not (legacy_hooks / "post-merge").exists() + blocked.assert_not_called() + if tracker is not None: + tracker.error.assert_called_once_with("hooks", cmind_cli._GIT_HOOK_CLEANUP_ERROR) + tracker.complete.assert_not_called() + tracker.skip.assert_not_called() + assert not cmind_cli._ai_cli_policy().local_selection_path(project).exists() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("git_hooks", [False, True]) +@pytest.mark.parametrize("failed_hook", ["pre-commit", "post-commit", "post-merge"]) +@pytest.mark.parametrize("operation", ["read_bytes", "unlink", "write_bytes"]) +def test_hook_permission_error_does_not_skip_other_cleanup( + project, legacy_hooks, monkeypatch, no_subprocess, git_hooks, failed_hook, operation, +): + names = ("pre-commit", "post-commit", "post-merge") + originals = {} + for name in names: + path = legacy_hooks / name + if operation == "write_bytes": + # Retaining user content forces a write rather than an unlink. + path.write_bytes(path.read_bytes() + b"echo user\r\n") + originals[name] = path.read_bytes() + original_operation = getattr(Path, operation) + attempted = [] + + def denied(path, *args, **kwargs): + if path.parent == legacy_hooks: + attempted.append(path.name) + if path.name == failed_hook: + raise PermissionError("UNTRUSTED_HOOK_ERROR") + return original_operation(path, *args, **kwargs) + + blocked = Mock() + for name in ( + "_install_git_post_commit_hook", "_install_git_post_merge_hook", + "_install_copilot_hooks", + ): + monkeypatch.setattr(cmind_cli, name, blocked) + with monkeypatch.context() as patch: + patch.setattr(Path, operation, denied) + with pytest.raises(RuntimeError) as exc: + cmind_cli._install_hooks(project, "copilot", git_hooks=git_hooks) + + assert str(exc.value) == cmind_cli._GIT_HOOK_CLEANUP_ERROR + assert attempted == list(names) + for name in names: + path = legacy_hooks / name + if name == failed_hook: + assert path.read_bytes() == originals[name] + elif operation == "write_bytes": + assert path.read_bytes() == b"#!/bin/sh\necho user\r\n" + else: + assert not path.exists() + blocked.assert_not_called() + assert not cmind_cli._ai_cli_policy().local_selection_path(project).exists() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("failed_hook", ["post-commit", "post-merge"]) +def test_opt_in_installer_error_is_fatal_and_stops_further_installation( + project, legacy_hooks, monkeypatch, no_subprocess, failed_hook, +): + installers = {} + for hook_name in ("post-commit", "post-merge"): + attr = f"_install_git_{hook_name.replace('-', '_')}_hook" + installer = Mock(wraps=getattr(cmind_cli, attr)) + if hook_name == failed_hook: + installer.side_effect = PermissionError("UNTRUSTED_HOOK_ERROR") + monkeypatch.setattr(cmind_cli, attr, installer) + installers[hook_name] = installer + status = Mock() + monkeypatch.setattr(cmind_cli, "_install_copilot_hooks", status) + + with pytest.raises(RuntimeError) as exc: + cmind_cli._install_hooks(project, "copilot", git_hooks=True) + + assert str(exc.value) == cmind_cli._GIT_HOOK_INSTALL_ERROR + installers["post-commit"].assert_called_once_with(project) + if failed_hook == "post-commit": + installers["post-merge"].assert_not_called() + assert not (legacy_hooks / "post-commit").exists() + else: + installers["post-merge"].assert_called_once_with(project) + _assert_pinned_git_hook((legacy_hooks / "post-commit").read_text(), "post-commit") + assert not (legacy_hooks / "pre-commit").exists() + assert not (legacy_hooks / "post-merge").exists() + status.assert_not_called() + assert not cmind_cli._ai_cli_policy().local_selection_path(project).exists() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("command", ["init", "update"]) +@pytest.mark.parametrize("existing_provider", [None, "claude"]) +@pytest.mark.parametrize("git_hooks,failure", [ + (False, "invalid-utf8"), (False, "read-error"), + (True, "invalid-utf8"), (True, "read-error"), + (True, "post-commit"), (True, "post-merge"), +]) +def test_cli_hook_reconciliation_failure_prevents_success_and_encode( + project, legacy_hooks, monkeypatch, no_subprocess, command, git_hooks, failure, + existing_provider, +): + policy = cmind_cli._ai_cli_policy() + selection = policy.local_selection_path(project) + if existing_provider: + policy.write_local_provider(project, existing_provider) + original_selection = selection.read_bytes() if selection.exists() else None + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + monkeypatch.chdir(project) + monkeypatch.setattr(cmind_cli, "show_banner", lambda: None) + for name in ( + "download_and_extract_template", "_setup_gitignore", "ensure_cmind_runtime_dirs", + ): + monkeypatch.setattr(cmind_cli, name, Mock()) + monkeypatch.setattr(cmind_cli, "_detect_install_method", lambda: "editable") + monkeypatch.setattr(cmind_cli, "_install_source", lambda: "editable") + monkeypatch.setattr(cmind_cli.shutil, "which", lambda _: "/installed/cmind") + encode = Mock() + monkeypatch.setattr(cmind_cli, "_maybe_offer_initial_encode", encode) + status = Mock() + monkeypatch.setattr(cmind_cli, "_install_copilot_hooks", status) + pre_commit = legacy_hooks / "pre-commit" + original = pre_commit.read_bytes() + if failure == "invalid-utf8": + original += b"# user bytes: \xff\r\n" + pre_commit.write_bytes(original) + elif failure == "read-error": + read_bytes = Path.read_bytes + + def denied(path): + if path.resolve() == pre_commit.resolve(): + raise PermissionError("UNTRUSTED_HOOK_ERROR") + return read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", denied) + else: + monkeypatch.setattr( + cmind_cli, f"_install_git_{failure.replace('-', '_')}_hook", + Mock(side_effect=PermissionError("UNTRUSTED_HOOK_ERROR")), + ) + args = [command, "--ai", "copilot", "--script", "sh", "--no-mcp", "--no-cmind-git"] + if command == "init": + args += ["--here", "--force", "--no-git", "--encode", "--ignore-agent-tools"] + else: + args += ["--no-upgrade"] + if git_hooks: + args.append("--git-hooks") + + result = CliRunner().invoke(cmind_cli.app, args) + + assert result.exit_code == 1, result.output + output = " ".join(result.output.split()) + if failure in ("invalid-utf8", "read-error"): + message = "Could not complete CoderMind Git hook cleanup." + with pre_commit.open("rb") as stream: + assert stream.read() == original + assert not (legacy_hooks / "post-commit").exists() + else: + message = "Could not install CoderMind Git sync hooks." + assert message in output + assert ("Initialization failed:" if command == "init" else "Update failed:") in output + assert "UNTRUSTED_HOOK_ERROR" not in output + assert "Project ready." not in output + assert "updated successfully" not in output + assert not (legacy_hooks / "post-merge").exists() + encode.assert_not_called() + status.assert_not_called() + save.assert_not_called() + if original_selection is None: + assert not selection.exists() + else: + assert selection.read_bytes() == original_selection + no_subprocess.assert_not_called() + + +# --------------------------------------------------------------------------- +# Dispatcher: all subprocesses mocked, all log writes under tmp_path +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("helper,expected", [ + ("is_git_repo", False), + ("_short_head_sha", "?"), + ("_read_core_hooks_path", None), +]) +def test_git_queries_without_trusted_git_do_not_launch_processes( + tmp_path, monkeypatch, no_subprocess, helper, expected, +): + """Unavailable trusted Git fails closed before any process is launched.""" + # Do not use project: it stubs _read_core_hooks_path for installer isolation. + resolver = Mock(side_effect=FileNotFoundError("No trusted Git available")) + monkeypatch.setattr(cmind_cli, "_resolve_git", resolver) + + assert getattr(cmind_cli, helper)(tmp_path) == expected + + resolver.assert_called_once_with(tmp_path) + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("name", ["post-commit", "post-merge"]) +@pytest.mark.parametrize("sync_result", [0, 1, "os-error"]) +@pytest.mark.parametrize("git_available", [False, True]) +def test_dispatcher_only_runs_deterministic_sync( + tmp_path, monkeypatch, trusted_git, no_subprocess, name, sync_result, git_available, +): + """Use pinned sync argv even when no trusted Git can supply the HEAD SHA.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CMIND_AI_CLI_CMD", "UNTRUSTED_CUSTOM_COMMAND") + monkeypatch.setattr(cmind_cli._storage, "find_workspace_root_from", lambda _: tmp_path) + logs = tmp_path / "home-store" / "logs" + monkeypatch.setattr(cmind_cli._storage, "workspace_logs_dir", lambda _: logs) + if not git_available: + cmind_cli._resolve_git.side_effect = FileNotFoundError("No trusted Git available") + git_command = [str(trusted_git), "-C", str(tmp_path), "rev-parse", "--short", "HEAD"] + sync_command = cmind_cli._cli_argv("script", "update_graphs.py", "sync") + assert sync_command == _expected_cli_argv("script", "update_graphs.py", "sync") + expected_sha = "abc123" if git_available else "?" + + def run(args, **kwargs): + assert not kwargs.get("shell", False) + if args == git_command: + assert git_available + return subprocess.CompletedProcess(args, 0, stdout="abc123\n") + assert args == sync_command + assert kwargs["cwd"] == str(tmp_path) + assert kwargs["env"]["CMIND_HOOK"] == name + assert kwargs["env"]["CMIND_HOOK_SHA"] == expected_sha + if sync_result == "os-error": + raise OSError("simulated unavailable pinned CLI") + return subprocess.CompletedProcess(args, sync_result) + + runner = Mock(side_effect=run) + monkeypatch.setattr(cmind_cli.subprocess, "run", runner) + with pytest.raises(cmind_cli.typer.Exit) as exc: + cmind_cli.hook(name) + assert exc.value.exit_code == 0 + cmind_cli._resolve_git.assert_called_once_with(tmp_path) + expected_calls = ([git_command] if git_available else []) + [sync_command] + assert [call.args[0] for call in runner.call_args_list] == expected_calls + assert runner.call_args.kwargs["env"]["CMIND_HOOK"] == name + assert runner.call_args.kwargs["env"]["CMIND_HOOK_SHA"] == expected_sha + assert not runner.call_args.kwargs.get("shell", False) + no_subprocess.assert_not_called() + assert (logs / "hooks.log").is_file() + assert not (logs / "update_rpg.log").exists() + assert not (logs / ".update_rpg.lock").exists() + + +# --------------------------------------------------------------------------- +# Shared policy loading and repo-only config validation +# --------------------------------------------------------------------------- + +def test_policy_loader_ignores_workspace_and_sys_path(tmp_path, monkeypatch, no_subprocess): + from cmind_cli import _assets + + for parent in (tmp_path, tmp_path / ".cmind" / "scripts"): + common = parent / "common" + common.mkdir(parents=True) + (common / "__init__.py").write_text("raise AssertionError('untrusted common')\n") + (common / "ai_cli_policy.py").write_text("raise AssertionError('untrusted policy')\n") + monkeypatch.chdir(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.setitem(sys.modules, "common.ai_cli_policy", ModuleType("common.ai_cli_policy")) + cmind_cli._ai_cli_policy.cache_clear() + try: + policy = cmind_cli._ai_cli_policy() + assert Path(policy.__file__).resolve() == (_assets.scripts_dir() / "common" / "ai_cli_policy.py").resolve() + assert policy.validate_provider("copilot") == "copilot" + assert cmind_cli._ai_cli_policy() is policy + assert cmind_cli._AI_TO_CLI_CMD == { + provider: " ".join(argv) for provider, argv in policy.PROVIDER_ARGV.items() + } + finally: + cmind_cli._ai_cli_policy.cache_clear() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("provider", list(cmind_cli._AI_TO_CLI_CMD)) +def test_workspace_config_writes_recommendation_only(tmp_path, no_subprocess, provider): + cmind_cli._write_workspace_config(tmp_path, provider) + text = (tmp_path / ".cmind" / "config.toml").read_text(encoding="utf-8") + assert cmind_cli.tomllib.loads(text) == {"cmind": {"recommended_provider": provider}} + assert "ai_cli_cmd" not in text + assert "Never execution authority" in text + policy = cmind_cli._ai_cli_policy() + assert policy.resolve_provider(tmp_path, environ={}) == "" + assert not policy.local_selection_path(tmp_path).exists() + + +@pytest.mark.parametrize("content", [ + b"# recommendation only\r\n[cmind]\r\nrecommended_provider = 'claude'\r\n", + b"# preserve formatting\r\n[cmind]\r\nai_provider = 'claude'\r\n", + b"# marker only\n[cmind]\n", + b"# no provider yet\n[other]\nsetting = 42\n", + *[f"[cmind]\nai_cli_cmd = '{command}'\n".encode() for command in cmind_cli._AI_TO_CLI_CMD.values()], +]) +def test_workspace_config_preserves_valid_existing_bytes(tmp_path, no_subprocess, capsys, content): + config = tmp_path / ".cmind" / "config.toml" + config.parent.mkdir() + config.write_bytes(content) + cmind_cli._write_workspace_config(tmp_path, "copilot") + assert config.read_bytes() == content + output = " ".join(capsys.readouterr().out.split()) + if b"ai_provider" in content or b"ai_cli_cmd" in content: + assert "legacy workspace AI hint preserved; it is not execution authority" in output + else: + assert "legacy workspace AI hint" not in output + assert not cmind_cli._ai_cli_policy().local_selection_path(tmp_path).exists() + + +@pytest.mark.parametrize("content", [ + "[cmind", 'cmind = "not a table"\n', + '[cmind]\nrecommended_provider = "UNTRUSTED_CUSTOM_COMMAND"\n', + '[cmind]\nrecommended_provider = ["copilot"]\n', + '[cmind]\nrecommended_provider = "copilot"\nai_provider = "claude"\n', + '[cmind]\nrecommended_provider = "copilot"\nai_cli_cmd = "copilot"\n', + '[cmind]\nai_provider = "UNTRUSTED_CUSTOM_COMMAND"\n', + '[cmind]\nai_provider = ["copilot"]\n', + '[cmind]\nai_provider = "claude"\nai_cli_cmd = "claude"\n', + '[cmind]\nai_cli_cmd = "/tmp/claude"\n', + '[cmind]\nai_cli_cmd = "claude --extra-option"\n', + '[cmind]\nai_cli_cmd = " copilot "\n', + '[cmind]\nai_cli_cmd = 42\n', +]) +def test_workspace_config_rejects_invalid_without_rewriting(tmp_path, no_subprocess, content): + config = tmp_path / ".cmind" / "config.toml" + config.parent.mkdir() + original = content.encode() + config.write_bytes(original) + with pytest.raises(cmind_cli._ai_cli_policy().AICommandPolicyError): + cmind_cli._write_workspace_config(tmp_path, "copilot") + assert config.read_bytes() == original + + +def test_workspace_config_rejects_unknown_selected_provider_without_creating_files(tmp_path, no_subprocess): + with pytest.raises(cmind_cli._ai_cli_policy().AICommandPolicyError): + cmind_cli._write_workspace_config(tmp_path, 'custom"\ncommand') + assert not (tmp_path / ".cmind").exists() + + +@pytest.mark.parametrize("command", ["init", "update"]) +def test_invalid_config_preflight_precedes_any_provisioning_or_upgrade(tmp_path, monkeypatch, no_subprocess, command): + monkeypatch.chdir(tmp_path) + config = tmp_path / ".cmind" / "config.toml" + config.parent.mkdir() + original = b'[cmind]\nai_cli_cmd = "UNTRUSTED_CUSTOM_COMMAND --flag"\n' + config.write_bytes(original) + monkeypatch.setattr(cmind_cli, "show_banner", lambda: None) + blocked = Mock(side_effect=lambda *a, **kw: pytest.fail("Side effect before policy rejection")) + for name in ( + "download_and_extract_template", "_write_source_marker", "_write_workspace_config", + "_setup_gitignore", "_generate_mcp_config", "_register_copilot_cli_global_mcp", + "_install_hooks", "ensure_cmind_runtime_dirs", "_detect_install_method", "_install_source", + "_upgrade_command", "check_tool", "select_with_arrows", + ): + monkeypatch.setattr(cmind_cli, name, blocked) + monkeypatch.setattr(cmind_cli.typer, "confirm", blocked) + for name in ("execv", "execvp"): + monkeypatch.setattr(cmind_cli.os, name, blocked) + policy = cmind_cli._ai_cli_policy() + monkeypatch.setattr(policy, "write_local_provider", blocked) + args = [command, "--ai", "copilot"] + if command == "init": + args.append("--here") + + result = CliRunner().invoke(cmind_cli.app, args) + + assert result.exit_code == 1, result.output + assert " ".join(result.output.split()) == cmind_cli._AI_POLICY_ERROR + assert "UNTRUSTED_CUSTOM_COMMAND" not in result.output + assert config.read_bytes() == original + assert list(tmp_path.iterdir()) == [config.parent] + assert not policy.local_selection_path(tmp_path).exists() + blocked.assert_not_called() + no_subprocess.assert_not_called() + + +# --------------------------------------------------------------------------- +# Explicit local consent versus integration-only init/update behavior +# --------------------------------------------------------------------------- + +@pytest.fixture +def cli_side_effects(project, monkeypatch, no_subprocess): + """Keep real policy/file writes, but stub all provisioning and execution.""" + monkeypatch.chdir(project) + monkeypatch.setattr(cmind_cli, "show_banner", lambda: None) + # CliRunner replaces sys.stdin. A module-local view lets these tests model + # TTY consent without changing Click's captured input or running a prompt. + cli_sys = SimpleNamespace(**vars(sys)) + cli_sys.stdin = Mock() + cli_sys.stdin.isatty.return_value = False + monkeypatch.setattr(cmind_cli, "sys", cli_sys) + monkeypatch.setattr(cmind_cli, "_detect_install_method", Mock(return_value="editable")) + monkeypatch.setattr(cmind_cli, "_install_source", Mock(return_value="editable")) + monkeypatch.setattr(cmind_cli.shutil, "which", lambda _: "/installed/cmind") + mocks = {} + for name in ( + "download_and_extract_template", "_write_source_marker", "_setup_gitignore", + "ensure_cmind_runtime_dirs", "_generate_mcp_config", "_register_copilot_cli_global_mcp", + "_install_hooks", "_maybe_offer_initial_encode", "check_tool", "is_git_repo", "init_git_repo", + ): + mocks[name] = Mock() + monkeypatch.setattr(cmind_cli, name, mocks[name]) + unexpected_prompt = Mock(side_effect=AssertionError("Unexpected interactive prompt")) + monkeypatch.setattr(cmind_cli, "select_with_arrows", unexpected_prompt) + monkeypatch.setattr(cmind_cli.typer, "confirm", unexpected_prompt) + mocks["prompt"] = unexpected_prompt + return mocks + + +def _consent_cli_args(command, ai="copilot", *, encode=False): + args = [command, "--script", "sh", "--no-mcp", "--no-cmind-git"] + if ai is not None: + args += ["--ai", ai] + if command == "init": + args += ["--here", "--force", "--no-git", "--ignore-agent-tools"] + args.append("--encode" if encode else "--no-encode") + else: + args.append("--no-upgrade") + return args + + +@pytest.mark.parametrize("command", ["init", "update"]) +@pytest.mark.parametrize("hint_key", ["recommended_provider", "ai_provider", "ai_cli_cmd"]) +@pytest.mark.parametrize("selected,hint", [("copilot", "claude"), ("claude", "copilot")]) +def test_explicit_cli_provider_wins_over_repository_hint( + project, monkeypatch, cli_side_effects, no_subprocess, command, hint_key, selected, hint, +): + policy = cmind_cli._ai_cli_policy() + config = project / ".cmind" / "config.toml" + original = f"# keep this hint\r\n[cmind]\r\n{hint_key} = '{hint}'\r\n".encode() + config.write_bytes(original) + selection = policy.local_selection_path(project) + + def hooks(*args, **kwargs): + assert not selection.exists() + assert config.read_bytes() == original + + cli_side_effects["_install_hooks"].side_effect = hooks + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args(command, selected)) + + assert result.exit_code == 0, result.output + save.assert_called_once() + assert save.call_args.args[0].resolve() == project.resolve() + assert save.call_args.args[1] == selected + assert policy.read_local_provider(project) == selected + assert policy.resolve_provider(project, environ={}) == selected + assert config.read_bytes() == original + assert cli_side_effects["download_and_extract_template"].call_args.args[1] == selected + assert f"User-local AI provider saved: {selected}" in " ".join(result.output.split()) + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("new_directory", [False, True]) +@pytest.mark.parametrize("existing_provider", [None, "claude"]) +def test_non_tty_init_without_ai_rejects_before_any_write_or_prompt( + project, isolated_home, monkeypatch, cli_side_effects, no_subprocess, + new_directory, existing_provider, +): + policy = cmind_cli._ai_cli_policy() + config = project / ".cmind" / "config.toml" + config.write_bytes(b'[cmind]\nrecommended_provider = "copilot"\n') + target = project / "new-project" if new_directory else project + if existing_provider: + policy.write_local_provider(target, existing_provider) + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + before = {p.relative_to(project): p.read_bytes() for p in project.rglob("*") if p.is_file()} + home_before = {p.relative_to(isolated_home): p.read_bytes() for p in isolated_home.rglob("*") if p.is_file()} + # Do not pass --force: rejection must happen even before the merge prompt. + args = ["init", "new-project"] if new_directory else ["init", "--here"] + + result = CliRunner().invoke(cmind_cli.app, args) + + assert result.exit_code == 1, result.output + assert "non-interactive init requires --ai copilot or --ai claude" in " ".join(result.output.split()) + assert {p.relative_to(project): p.read_bytes() for p in project.rglob("*") if p.is_file()} == before + assert {p.relative_to(isolated_home): p.read_bytes() for p in isolated_home.rglob("*") if p.is_file()} == home_before + if new_directory: + assert not target.exists() + assert policy.read_local_provider(target) == (existing_provider or "") + if not existing_provider: + assert not policy.local_selection_path(target).exists() + assert not (isolated_home / ".cmind").exists() + for mock in cli_side_effects.values(): + mock.assert_not_called() + save.assert_not_called() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("hooks_succeed", [False, True]) +def test_interactive_init_choice_is_explicit_consent_only_after_hooks( + project, monkeypatch, cli_side_effects, no_subprocess, hooks_succeed, +): + policy = cmind_cli._ai_cli_policy() + selection = policy.local_selection_path(project) + cmind_cli.sys.stdin.isatty.return_value = True + choose = Mock(return_value="claude") + monkeypatch.setattr(cmind_cli, "select_with_arrows", choose) + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + + def hooks(*args, **kwargs): + assert not selection.exists() + if not hooks_succeed: + raise RuntimeError(cmind_cli._GIT_HOOK_CLEANUP_ERROR) + + cli_side_effects["_install_hooks"].side_effect = hooks + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args("init", None, encode=True)) + + choose.assert_called_once() + assert "provider to save locally" in choose.call_args.args[1] + assert result.exit_code == (0 if hooks_succeed else 1), result.output + if hooks_succeed: + save.assert_called_once() + assert policy.read_local_provider(project) == "claude" + else: + save.assert_not_called() + assert not selection.exists() + assert "Project ready." not in result.output + cli_side_effects["_maybe_offer_initial_encode"].assert_not_called() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("hint_key", ["recommended_provider", "ai_provider", "ai_cli_cmd"]) +def test_update_auto_detection_never_creates_local_consent( + project, isolated_home, monkeypatch, cli_side_effects, no_subprocess, hint_key, +): + policy = cmind_cli._ai_cli_policy() + config = project / ".cmind" / "config.toml" + original = f"[cmind]\n{hint_key} = 'copilot'\n".encode() + config.write_bytes(original) + (project / ".claude").mkdir() + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args("update", None)) + + assert result.exit_code == 0, result.output + assert cli_side_effects["download_and_extract_template"].call_args.args[1] == "claude" + assert policy.resolve_provider(project, environ={}) == "" + assert not policy.local_selection_path(project).exists() + assert not (isolated_home / ".cmind" / "execution").exists() + assert config.read_bytes() == original + assert "Integration only: user-local AI selection will not be changed" in " ".join(result.output.split()) + save.assert_not_called() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("provider,expected_integration", [("claude", "claude"), ("gemini", "copilot")]) +def test_update_without_ai_preserves_local_bytes_and_prefers_supported_local_provider( + project, monkeypatch, cli_side_effects, no_subprocess, provider, expected_integration, +): + policy = cmind_cli._ai_cli_policy() + policy.write_local_provider(project, provider) + selection = policy.local_selection_path(project) + # Valid but non-default formatting detects even a same-value rewrite. + original = (json.dumps(json.loads(selection.read_text(encoding="utf-8")), indent=2) + "\r\n").encode() + selection.write_bytes(original) + config = project / ".cmind" / "config.toml" + hint = b"[cmind]\nrecommended_provider = 'copilot'\n" + config.write_bytes(hint) + (project / ".github").mkdir() + detect = Mock(wraps=cmind_cli._detect_ai_agent) + monkeypatch.setattr(cmind_cli, "_detect_ai_agent", detect) + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args("update", None)) + + assert result.exit_code == 0, result.output + assert cli_side_effects["download_and_extract_template"].call_args.args[1] == expected_integration + assert selection.read_bytes() == original + assert policy.read_local_provider(project) == provider + assert config.read_bytes() == hint + if provider in cmind_cli.AGENT_CONFIG: + detect.assert_not_called() + else: + detect.assert_called_once() + save.assert_not_called() + no_subprocess.assert_not_called() + + +def test_update_non_tty_with_only_repo_hint_fails_actionably_without_prompt( + project, monkeypatch, cli_side_effects, no_subprocess, +): + policy = cmind_cli._ai_cli_policy() + config = project / ".cmind" / "config.toml" + original = b"[cmind]\nrecommended_provider = 'copilot'\n" + config.write_bytes(original) + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args("update", None)) + + assert result.exit_code == 1, result.output + output = " ".join(result.output.split()) + assert "cannot determine AI integration in non-interactive update" in output + assert "--ai copilot or --ai claude" in output + assert config.read_bytes() == original + assert not policy.local_selection_path(project).exists() + for mock in cli_side_effects.values(): + mock.assert_not_called() + cmind_cli._detect_install_method.assert_not_called() + save.assert_not_called() + no_subprocess.assert_not_called() + + +def test_update_interactive_choice_is_integration_only( + project, monkeypatch, cli_side_effects, no_subprocess, +): + policy = cmind_cli._ai_cli_policy() + cmind_cli.sys.stdin.isatty.return_value = True + choose = Mock(return_value="copilot") + monkeypatch.setattr(cmind_cli, "select_with_arrows", choose) + save = Mock(wraps=policy.write_local_provider) + monkeypatch.setattr(policy, "write_local_provider", save) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args("update", None)) + + assert result.exit_code == 0, result.output + choose.assert_called_once() + assert "integration only" in choose.call_args.args[1] + assert not policy.local_selection_path(project).exists() + assert policy.resolve_provider(project, environ={}) == "" + save.assert_not_called() + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("command", ["init", "update"]) +def test_explicit_provider_change_follows_hooks_and_precedes_success_and_encode( + project, monkeypatch, cli_side_effects, no_subprocess, command, +): + policy = cmind_cli._ai_cli_policy() + policy.write_local_provider(project, "claude") + selection = policy.local_selection_path(project) + original = selection.read_bytes() + events = [] + write_local = policy.write_local_provider + complete = cmind_cli.StepTracker.complete + + def hooks(*args, **kwargs): + assert selection.read_bytes() == original + events.append("hooks") + + def save(workspace, provider): + assert events == ["hooks"] + assert selection.read_bytes() == original + write_local(workspace, provider) + events.append("save") + + def completed(tracker, key, detail=""): + if key == "final": + assert events == ["hooks", "save"] + assert policy.read_local_provider(project) == "copilot" + events.append("final") + return complete(tracker, key, detail) + + def encode(*args, **kwargs): + assert kwargs["encode_choice"] is True + assert events == ["hooks", "save", "final"] + assert policy.read_local_provider(project) == "copilot" + events.append("encode") + + cli_side_effects["_install_hooks"].side_effect = hooks + cli_side_effects["_maybe_offer_initial_encode"].side_effect = encode + monkeypatch.setattr(policy, "write_local_provider", save) + monkeypatch.setattr(cmind_cli.StepTracker, "complete", completed) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args(command, encode=True)) + + assert result.exit_code == 0, result.output + assert events == ["hooks", "save", "final"] + (["encode"] if command == "init" else []) + assert policy.read_local_provider(project) == "copilot" + assert selection.read_bytes() != original + no_subprocess.assert_not_called() + + +@pytest.mark.parametrize("command", ["init", "update"]) +@pytest.mark.parametrize("existing_provider", [None, "claude"]) +@pytest.mark.parametrize("failure", ["os-error", "policy-error", "atomic-replace"]) +def test_local_selection_write_failure_is_fatal_before_success_or_encode( + project, monkeypatch, cli_side_effects, no_subprocess, command, existing_provider, failure, +): + policy = cmind_cli._ai_cli_policy() + selection = policy.local_selection_path(project) + if existing_provider: + policy.write_local_provider(project, existing_provider) + original = selection.read_bytes() if selection.exists() else None + save = Mock(wraps=policy.write_local_provider) + if failure == "atomic-replace": + monkeypatch.setattr(policy.os, "replace", Mock(side_effect=PermissionError("PRIVATE_LOCAL_ERROR"))) + elif failure == "policy-error": + save.side_effect = policy.AICommandPolicyError("PRIVATE_LOCAL_ERROR") + else: + save.side_effect = PermissionError("PRIVATE_LOCAL_ERROR") + monkeypatch.setattr(policy, "write_local_provider", save) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args(command, encode=True)) + + assert result.exit_code == 1, result.output + output = " ".join(result.output.replace("│", " ").split()) + assert "Cannot read or save user-local AI selection" in output + assert "--ai copilot or --ai claude" in output + assert "PRIVATE_LOCAL_ERROR" not in output + assert "Project ready." not in output + assert "updated successfully" not in output + assert "User-local AI provider saved" not in output + cli_side_effects["_install_hooks"].assert_called_once() + cli_side_effects["_maybe_offer_initial_encode"].assert_not_called() + save.assert_called_once() + if original is None: + assert not selection.exists() + else: + assert selection.read_bytes() == original + assert not list(selection.parent.glob(".selection-*.tmp")) + no_subprocess.assert_not_called() + + +def test_invalid_local_selection_blocks_implicit_update_without_overwriting( + project, monkeypatch, cli_side_effects, no_subprocess, +): + policy = cmind_cli._ai_cli_policy() + selection = policy.local_selection_path(project) + selection.parent.mkdir(parents=True) + original = b'{"ai_provider": "claude"}\n' + selection.write_bytes(original) + (project / ".github").mkdir() + detect = Mock(wraps=cmind_cli._detect_ai_agent) + monkeypatch.setattr(cmind_cli, "_detect_ai_agent", detect) + + result = CliRunner().invoke(cmind_cli.app, _consent_cli_args("update", None)) + + assert result.exit_code == 1, result.output + assert "Cannot read or save user-local AI selection" in " ".join(result.output.split()) + assert selection.read_bytes() == original + detect.assert_not_called() + for mock in cli_side_effects.values(): + mock.assert_not_called() + cmind_cli._detect_install_method.assert_not_called() + no_subprocess.assert_not_called() + + +def test_source_marker_and_storage_updates_cannot_erase_local_selection(project, no_subprocess): + policy = cmind_cli._ai_cli_policy() + policy.write_local_provider(project, "claude") + selection = policy.local_selection_path(project) + original = selection.read_bytes() + + cmind_cli._write_workspace_config(project, "copilot") + for channel in (cmind_cli._SOURCE_BUNDLE, cmind_cli._SOURCE_LEGACY): + cmind_cli._write_source_marker(project, channel) + assert cmind_cli._read_source_marker(project) == channel + assert selection.read_bytes() == original + assert policy.read_local_provider(project) == "claude" + assert not selection.is_relative_to(cmind_cli._storage.home_workspace_dir(project)) + assert "ai_provider" not in cmind_cli._storage.read_meta(project) + assert policy.read_workspace_provider(project) == "copilot" + assert policy.resolve_provider(project, environ={}) == "claude" + no_subprocess.assert_not_called() + + # --------------------------------------------------------------------------- # update_graphs.py status # --------------------------------------------------------------------------- diff --git a/CoderMind/tests/test_initial_encode_prompt.py b/CoderMind/tests/test_initial_encode_prompt.py index 0d1871b..1b0ad27 100644 --- a/CoderMind/tests/test_initial_encode_prompt.py +++ b/CoderMind/tests/test_initial_encode_prompt.py @@ -25,6 +25,40 @@ sys.path.insert(0, str(_project_root / "src")) import cmind_cli # noqa: E402 +from cmind_cli import _assets # noqa: E402 + + +@pytest.fixture(autouse=True) +def isolated_home(tmp_path_factory, monkeypatch): + """Keep storage outside the workspace and away from the real user home.""" + home = tmp_path_factory.mktemp("encode-home") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + # Real fake-encoder subprocesses must inherit the same isolation. + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + return home + + +@pytest.fixture +def trusted_scripts(tmp_path_factory, monkeypatch): + """Model installed assets in a temporary sibling of the workspace.""" + scripts = tmp_path_factory.mktemp("encode-scripts") + monkeypatch.setattr(_assets, "scripts_dir", lambda: scripts) + return scripts + + +@pytest.fixture +def legacy_encoder(tmp_path): + """A workspace encoder that would only write a benign execution marker.""" + script = tmp_path / ".cmind" / "scripts" / "rpg_encoder" / "run_encode.py" + script.parent.mkdir(parents=True) + script.write_text( + "from pathlib import Path\n" + "Path(__file__).with_suffix('.executed').write_text(\n" + " 'legacy workspace encoder ran\\n', encoding='utf-8')\n", + encoding="utf-8", + ) + return script # --------------------------------------------------------------------------- @@ -72,14 +106,21 @@ def test_workspace_has_python_code_empty(tmp_path): # _maybe_offer_initial_encode — short-circuits # --------------------------------------------------------------------------- -def test_skip_when_rpg_already_exists(tmp_path): - """If rpg.json is already present, never prompt nor run.""" +@pytest.mark.parametrize("storage_location", ["home", "legacy"]) +def test_skip_when_rpg_already_exists(tmp_path, isolated_home, storage_location): + """Existing home-side or legacy rpg.json prevents both prompting and running.""" (tmp_path / "main.py").write_text("\n") - rpg_file = tmp_path / ".cmind" / "data" / "rpg.json" + if storage_location == "home": + rpg_file = cmind_cli._storage.workspace_data_dir(tmp_path) / "rpg.json" + assert rpg_file.resolve().is_relative_to(isolated_home.resolve()) + assert not (tmp_path / ".cmind" / "data" / "rpg.json").exists() + else: + rpg_file = tmp_path / ".cmind" / "data" / "rpg.json" rpg_file.parent.mkdir(parents=True) rpg_file.write_text("{}") - with patch.object(cmind_cli, "_run_initial_encode") as run, \ + with patch("sys.stdin.isatty", return_value=True), \ + patch.object(cmind_cli, "_run_initial_encode") as run, \ patch("typer.confirm") as confirm: cmind_cli._maybe_offer_initial_encode(tmp_path, encode_choice=None) cmind_cli._maybe_offer_initial_encode(tmp_path, encode_choice=True) @@ -175,10 +216,50 @@ def test_keyboard_interrupt_during_prompt_does_not_propagate(tmp_path): # _run_initial_encode — missing encoder script # --------------------------------------------------------------------------- -def test_run_initial_encode_missing_script_returns_false(tmp_path): - """If .cmind/scripts/rpg_encoder/run_encode.py is absent, we warn - and return False without raising.""" - assert cmind_cli._run_initial_encode(tmp_path) is False +def test_run_initial_encode_missing_script_returns_false( + tmp_path, trusted_scripts, legacy_encoder, +): + """Missing installed assets never fall back to an existing workspace encoder.""" + assert list(trusted_scripts.iterdir()) == [] + assert legacy_encoder.is_file() + with patch.object( + cmind_cli.subprocess, "Popen", side_effect=AssertionError("Unexpected encoder subprocess"), + ) as popen: + assert cmind_cli._run_initial_encode(tmp_path) is False + + popen.assert_not_called() + assert not legacy_encoder.with_suffix(".executed").exists() + + +@pytest.mark.parametrize("link_kind", ["file", "directory"]) +def test_run_initial_encode_refuses_asset_symlink_escape( + tmp_path, trusted_scripts, legacy_encoder, link_kind, +): + """An asset symlink cannot authorize execution outside the installed root.""" + encoder_dir = trusted_scripts / "rpg_encoder" + encoder = encoder_dir / "run_encode.py" + try: + if link_kind == "directory": + encoder_dir.symlink_to(legacy_encoder.parent, target_is_directory=True) + else: + encoder_dir.mkdir() + encoder.symlink_to(legacy_encoder) + except OSError as exc: + if isinstance(exc, PermissionError) or getattr(exc, "winerror", None) == 1314: + pytest.skip(f"Symlink creation is not permitted: {exc}") + raise + + assert encoder.is_file() + assert encoder.resolve() == legacy_encoder.resolve() + assert not encoder.resolve().is_relative_to(trusted_scripts.resolve()) + with patch.object( + cmind_cli.subprocess, "Popen", side_effect=AssertionError("Unexpected encoder subprocess"), + ) as popen: + assert cmind_cli._resolve_script_path("rpg_encoder/run_encode.py") is None + assert cmind_cli._run_initial_encode(tmp_path) is False + + popen.assert_not_called() + assert not legacy_encoder.with_suffix(".executed").exists() # --------------------------------------------------------------------------- @@ -271,16 +352,16 @@ def test_parse_line_unknown_is_ignored(): # --------------------------------------------------------------------------- -# _run_initial_encode — end-to-end with a mocked subprocess +# _run_initial_encode — real subprocess tests; exclude from no-real-process runs # --------------------------------------------------------------------------- -def _make_fake_encoder(tmp_path: Path, exit_code: int, stderr_lines: list, stdout_text: str = "") -> Path: +def _make_fake_encoder(scripts_root: Path, exit_code: int, stderr_lines: list, stdout_text: str = "") -> Path: """Write a real Python script that mimics the encoder's IO. Using a real subprocess (rather than mocking Popen) keeps the test honest: it exercises the actual threaded reader + Progress loop. """ - encoder_dir = tmp_path / ".cmind" / "scripts" / "rpg_encoder" + encoder_dir = scripts_root / "rpg_encoder" encoder_dir.mkdir(parents=True) script = encoder_dir / "run_encode.py" payload = { @@ -298,15 +379,16 @@ def _make_fake_encoder(tmp_path: Path, exit_code: int, stderr_lines: list, stdou "if payload['stdout_text']:\n" " sys.stdout.write(payload['stdout_text'])\n" " sys.stdout.flush()\n" - "sys.exit(payload['exit_code'])\n" + "sys.exit(payload['exit_code'])\n", + encoding="utf-8", ) return script -def test_run_initial_encode_success_writes_log(tmp_path): +def test_run_initial_encode_success_writes_log(tmp_path, trusted_scripts): """A 0-exit encoder is reported as success and its stderr lands in encode.log.""" _make_fake_encoder( - tmp_path, + trusted_scripts, exit_code=0, stderr_lines=[ "RPGParser - INFO - Generating repo info (max_iters=3)", @@ -328,10 +410,36 @@ def test_run_initial_encode_success_writes_log(tmp_path): assert "process_class_batch" in contents -def test_run_initial_encode_failure_returns_false(tmp_path): +def test_run_initial_encode_success_ignores_legacy_workspace_encoder( + tmp_path, trusted_scripts, legacy_encoder, isolated_home, +): + """Execute trusted assets, not the benign marker script in the workspace.""" + trusted_output = "TRUSTED_ASSET_ENCODER_EXECUTED" + trusted_encoder = _make_fake_encoder( + trusted_scripts, + exit_code=0, + stderr_lines=[trusted_output], + stdout_text='{"status": "success", "source": "trusted-asset"}\n', + ) + assert not trusted_encoder.resolve().is_relative_to(tmp_path.resolve()) + assert cmind_cli._resolve_script_path("rpg_encoder/run_encode.py") == trusted_encoder.resolve() + assert legacy_encoder.is_file() + marker = legacy_encoder.with_suffix(".executed") + assert not marker.exists() + + assert cmind_cli._run_initial_encode(tmp_path) is True + + assert not marker.exists() + log = cmind_cli._storage.workspace_logs_dir(tmp_path) / "encode.log" + assert log.resolve().is_relative_to(isolated_home.resolve()) + assert trusted_output in log.read_text(encoding="utf-8") + assert not (tmp_path / ".cmind" / "logs").exists() + + +def test_run_initial_encode_failure_returns_false(tmp_path, trusted_scripts): """A non-zero exit is reported as failure and we still get a log file.""" _make_fake_encoder( - tmp_path, + trusted_scripts, exit_code=1, stderr_lines=["RPGParser - ERROR - boom"], stdout_text='{"status": "failed", "error": "boom"}\n', diff --git a/CoderMind/tests/test_product_execution_integration.py b/CoderMind/tests/test_product_execution_integration.py new file mode 100644 index 0000000..4227e5d --- /dev/null +++ b/CoderMind/tests/test_product_execution_integration.py @@ -0,0 +1,142 @@ +"""Real local subprocess checks; run only with the isolated --integration runner. + +No AI tools, downloads, or developer configuration are used. Same-name native +fixtures are unmodified system binaries, not executable attack payloads. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "src"), str(ROOT / "scripts")] + + +@pytest.fixture +def workspace(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + ws = tmp_path / "workspace with spaces" + ws.mkdir() + for key in list(os.environ): + if key.startswith(("CMIND_", "GIT_")): + monkeypatch.delenv(key, raising=False) + for key in ("HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", + "XDG_DATA_HOME", "XDG_CACHE_HOME"): + monkeypatch.setenv(key, str(home)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("PYTHONDONTWRITEBYTECODE", "1") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.chdir(ws) + from common.trusted_tools import resolve_git + + template = tmp_path / "empty-template" + template.mkdir() + git = resolve_git(ws) + subprocess.run([git, "init", "--template=" + str(template)], cwd=ws, check=True, capture_output=True) + return ws + + +def invoke(args, workspace): + from cmind_cli._trusted_tools import cli_argv + + return subprocess.run(cli_argv(*args), cwd=workspace, capture_output=True, + text=True, encoding="utf-8", errors="replace", timeout=90) + + +def git_run(workspace, *args): + from common.trusted_tools import resolve_git + + return subprocess.run([resolve_git(workspace), *args], cwd=workspace, + capture_output=True, text=True, check=True, timeout=90) + + +def test_real_init_and_opt_in_commit_use_installed_entry(workspace, monkeypatch): + import cmind_cli + + # New command names cannot be resolved via PATH/cwd, even when lookalikes + # exist. These text fixtures are never legitimate executable candidates. + for name in ("cmind", "cmind.cmd", "cmind.ps1"): + (workspace / name).write_text("must not execute", encoding="utf-8") + fake_module = workspace / "cmind_cli" + fake_module.mkdir() + (fake_module / "__init__.py").write_text("raise AssertionError('workspace package imported')", encoding="utf-8") + monkeypatch.setenv("PATH", str(workspace) + os.pathsep + os.environ.get("PATH", "")) + + result = invoke(["init", "--here", "--ai", "claude", "--script", "sh", "--force", + "--no-encode", "--ignore-agent-tools", "--no-mcp", "--no-cmind-git", + "--git-hooks"], workspace) + assert result.returncode == 0, result.stdout + result.stderr + hook = (workspace / ".git/hooks/post-commit").read_text(encoding="utf-8") + assert "_cli_entry.py" in hook + assert "command -v cmind" not in hook + (workspace / "readme.txt").write_text("inert integration fixture\n", encoding="utf-8") + git_run(workspace, "add", "readme.txt") + git_run(workspace, "-c", "user.name=Security Test", "-c", "user.email=test@example.invalid", + "-c", "commit.gpgsign=false", "commit", "-m", "inert fixture") + log = cmind_cli._storage.workspace_logs_dir(workspace) / "hooks.log" + content = log.read_text(encoding="utf-8") + assert "post-commit fired" in content + # No graph is seeded: the real packaged sync must report that condition, + # not import the workspace package or fall back to an AI encoder. + assert "Error: rpg.json not found at" in content, content + assert "foreground-sync: done (exit 1)" in content, content + assert "phase2" not in content + assert not (log.parent / "update_rpg.log").exists() + + merged = invoke(["hook", "post-merge"], workspace) + assert merged.returncode == 0 + assert "sync: done (exit 1)" in log.read_text(encoding="utf-8") + + # Default update reconciles the pinned hooks as well, not just old stubs. + updated = invoke(["update", "--ai", "claude", "--script", "sh", "--no-upgrade", + "--no-cmind-git"], workspace) + assert updated.returncode == 0, updated.stdout + updated.stderr + assert not (workspace / ".git/hooks/post-commit").exists() + assert not (workspace / ".git/hooks/post-merge").exists() + + +@pytest.mark.skipif(os.name != "nt", reason="Windows native executable lookup") +def test_windows_process_images_ignore_workspace_binaries(workspace, monkeypatch): + import _winapi + import ctypes + from ctypes import wintypes + import cmind_cli + from common.trusted_tools import resolve_git + + system_binary = Path(os.environ["SystemRoot"]) / "System32/whoami.exe" + for name in ("git.exe", "cmind.exe"): + shutil.copyfile(system_binary, workspace / name) + monkeypatch.setenv("PATH", str(workspace) + os.pathsep + os.environ.get("PATH", "")) + expected_git = Path(resolve_git(workspace)).resolve() + images = [] + create_process = _winapi.CreateProcess + query = ctypes.WinDLL("kernel32", use_last_error=True).QueryFullProcessImageNameW + query.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)] + query.restype = wintypes.BOOL + + def record_image(*args, **kwargs): + handles = create_process(*args, **kwargs) + buf = ctypes.create_unicode_buffer(32768) + size = wintypes.DWORD(len(buf)) + if not query(handles[0], 0, buf, ctypes.byref(size)): + raise ctypes.WinError(ctypes.get_last_error()) + images.append(Path(buf.value).resolve()) + return handles + + monkeypatch.setattr(_winapi, "CreateProcess", record_image) + assert cmind_cli.is_git_repo(workspace) + cmind_cli._short_head_sha(workspace) + # Non-workspace status command avoids any network/AI or graph mutations. + log = workspace / "hook-probe.log" + rc = cmind_cli._hook_run_foreground(workspace, log, os.environ.copy(), + ["update_graphs.py", "status"], "probe") + assert rc == 0, log.read_text(encoding="utf-8") + assert images[:2] == [expected_git, expected_git] + assert images[2] == Path(sys.executable).resolve() + assert all(not path.is_relative_to(workspace) for path in images) diff --git a/CoderMind/tests/test_product_execution_security.py b/CoderMind/tests/test_product_execution_security.py new file mode 100644 index 0000000..161ed8e --- /dev/null +++ b/CoderMind/tests/test_product_execution_security.py @@ -0,0 +1,193 @@ +"""No-process regressions for product Git lookup and installed encoder selection.""" + +import ast +import logging +import os +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "src"), str(ROOT / "scripts")] + + +@pytest.fixture(autouse=True) +def isolated(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / ".git").mkdir() + monkeypatch.chdir(workspace) + for name in ("CMIND_AI_PROVIDER", "CMIND_AI_CLI_CMD", "CMIND_HOOK"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(subprocess, "Popen", Mock(side_effect=AssertionError("Real process forbidden"))) + return workspace + + +@pytest.fixture +def tools(isolated): + from common import trusted_tools + + return trusted_tools + + +def executable(directory, name="git", *, windows): + directory.mkdir(parents=True, exist_ok=True) + path = directory / (name + ".exe" if windows else name) + path.write_text("inert test executable; never launch", encoding="utf-8") + path.chmod(0o755) + return path.resolve() + + +@pytest.mark.parametrize("windows", [False, True]) +@pytest.mark.parametrize("name", ["git", "uv", "pipx"]) +def test_only_external_absolute_tool_is_selected(tmp_path, isolated, tools, monkeypatch, windows, name): + monkeypatch.setattr(tools, "_IS_WINDOWS", windows) + executable(isolated, name, windows=windows) + executable(isolated / "bin", name, windows=windows) + expected = executable(tmp_path / "external tools", name, windows=windows) + path = os.pathsep.join(("", ".", "bin", str(isolated), str(isolated / "bin"), str(expected.parent))) + assert tools.resolve_tool(name, isolated, search_path=path) == str(expected) + with pytest.raises(FileNotFoundError): + tools.resolve_tool(name, isolated, search_path=os.pathsep.join(("", ".", str(isolated)))) + + +def test_parent_checkout_is_excluded_from_nested_cwd(tmp_path, isolated, tools, monkeypatch): + nested = isolated / "src" / "nested" + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + bad = executable(isolated / "tools", windows=tools._IS_WINDOWS) + home_repo = tmp_path / "home-store" + home_repo.mkdir() + with pytest.raises(FileNotFoundError): + tools.resolve_tool("git", home_repo, search_path=str(bad.parent)) + + +@pytest.mark.parametrize("suffix", [".cmd", ".bat", ".ps1"]) +def test_windows_never_executes_script_wrappers(tmp_path, isolated, tools, monkeypatch, suffix): + monkeypatch.setattr(tools, "_IS_WINDOWS", True) + directory = tmp_path / "external" + directory.mkdir() + (directory / ("git" + suffix)).write_text("never launch", encoding="utf-8") + monkeypatch.setenv("PATHEXT", suffix) + with pytest.raises(FileNotFoundError): + tools.resolve_tool("git", isolated, search_path=str(directory)) + + +def link_or_skip(link, target, *, directory=False): + try: + link.symlink_to(target, target_is_directory=directory) + except (OSError, NotImplementedError): + pytest.skip("Symlink creation unavailable") + + +@pytest.mark.parametrize("kind", ["directory-into-workspace", "file-into-workspace", "workspace-link-out"]) +def test_redirected_tool_paths_cannot_bypass_boundary(tmp_path, isolated, tools, kind): + bad = executable(isolated / "bin", windows=tools._IS_WINDOWS) + external = tmp_path / "external" + if kind == "directory-into-workspace": + link_or_skip(external, bad.parent, directory=True) + directory = external + elif kind == "file-into-workspace": + external.mkdir() + link_or_skip(external / bad.name, bad) + directory = external + else: + executable(external, windows=tools._IS_WINDOWS) + directory = isolated / "external-link" + link_or_skip(directory, external, directory=True) + with pytest.raises(FileNotFoundError): + tools.resolve_tool("git", isolated, search_path=str(directory)) + + +def test_unknown_tool_is_not_a_command_channel(isolated, tools): + for name in ("python", "git --version", "../git", "cmd.exe"): + with pytest.raises(ValueError): + tools.resolve_tool(name, isolated) + + +def test_product_git_helpers_use_the_same_external_executable(tmp_path, isolated, tools, monkeypatch): + import cmind_cli + from cmind_cli import _inner_git + from common import git_utils, generated_artifacts + + safe = executable(tmp_path / "external", windows=tools._IS_WINDOWS) + executable(isolated, windows=tools._IS_WINDOWS) + monkeypatch.setenv("PATH", os.pathsep.join((str(isolated), str(safe.parent)))) + run = Mock(return_value=subprocess.CompletedProcess([], 0, stdout="abc123\n", stderr="")) + monkeypatch.setattr(subprocess, "run", run) + assert cmind_cli.is_git_repo(isolated) + assert cmind_cli._short_head_sha(isolated) == "abc123" + cmind_cli._read_core_hooks_path(isolated) + _inner_git._run_git(isolated, "status") + assert git_utils._run_git_readonly(["rev-parse", "HEAD"], isolated) == "abc123" + runner = git_utils.GitRunner.__new__(git_utils.GitRunner) + runner.repo_path = isolated + runner.logger = logging.getLogger(__name__) + assert runner.run_git(["status"]).success + assert generated_artifacts._run_git(isolated, ["status"]) is not None + assert run.call_count == 7 + assert all(call.args[0][0] == str(safe) for call in run.call_args_list) + + +@pytest.mark.parametrize("installed_exists", [False, True]) +def test_initial_encode_never_falls_back_to_workspace(tmp_path, isolated, monkeypatch, installed_exists): + import cmind_cli + from cmind_cli import _assets + + legacy = isolated / ".cmind/scripts/rpg_encoder/run_encode.py" + legacy.parent.mkdir(parents=True) + legacy.write_text("raise AssertionError('workspace script must never run')", encoding="utf-8") + scripts = tmp_path / "trusted-assets" + scripts.mkdir() + encoder = scripts / "rpg_encoder/run_encode.py" + if installed_exists: + encoder.parent.mkdir() + encoder.write_text("# installed fixture", encoding="utf-8") + monkeypatch.setattr(_assets, "scripts_dir", lambda: scripts) + spawn = Mock(side_effect=OSError("stop before creating process")) + monkeypatch.setattr(cmind_cli.subprocess, "Popen", spawn) + assert cmind_cli._run_initial_encode(isolated) is False + if installed_exists: + spawn.assert_called_once() + assert Path(spawn.call_args.args[0][1]) == encoder.resolve() + assert spawn.call_args.kwargs["cwd"] == str(isolated) + else: + spawn.assert_not_called() + + +def test_cli_self_invocation_pins_interpreter_and_bootstrap(isolated, monkeypatch): + import cmind_cli + from cmind_cli import _trusted_tools + + monkeypatch.setenv("PATH", str(isolated)) + command = _trusted_tools.cli_argv("script", "update_graphs.py", "sync") + assert command[0] == sys.executable + assert command[1] == "-I" + assert Path(command[2]) == Path(cmind_cli.__file__).resolve().with_name("_cli_entry.py") + assert command[3:] == ["script", "update_graphs.py", "sync"] + assert not Path(command[2]).is_relative_to(isolated) + + +def test_no_bare_git_or_cmind_subprocess_literals_remain(): + # Guard every pipeline, not just the three originally reported entry points. + import cmind_cli + from cmind_cli import _assets + + for root in (Path(cmind_cli.__file__).parent, _assets.scripts_dir()): + for path in root.rglob("*.py"): + if "__pycache__" in path.parts: + continue + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in {"run", "Popen", "call", "check_output", "check_call"} + and node.args and isinstance(node.args[0], (ast.List, ast.Tuple))): + continue + items = node.args[0].elts + if items and isinstance(items[0], ast.Constant): + assert items[0].value not in ("git", "cmind"), (str(path), node.lineno) diff --git a/CoderMind/tests/test_security_test_sandbox.py b/CoderMind/tests/test_security_test_sandbox.py new file mode 100644 index 0000000..19981ab --- /dev/null +++ b/CoderMind/tests/test_security_test_sandbox.py @@ -0,0 +1,218 @@ +"""Exercise sandbox cleanup without privileges, processes or external writes.""" + +import errno +import importlib.util +import os +from pathlib import Path +import shutil +import sys + +import pytest + + +_SPEC = importlib.util.spec_from_file_location( + "security_sandbox_under_test", Path(__file__).with_name("run_security_tests.py") +) +assert _SPEC is not None and _SPEC.loader is not None +_RUNNER = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_RUNNER) +SandboxAudit = _RUNNER.SandboxAudit + + +@pytest.mark.parametrize("form", ["explicit", "argv", "windows-command-line"]) +def test_integration_guard_accepts_external_absolute_executable(tmp_path, form): + sandbox = tmp_path / "sandbox" + sandbox.mkdir() + executable = tmp_path / "external tools" / "python.exe" + guard = _RUNNER.IntegrationAudit(sandbox) + if form == "explicit": + args = (str(executable), [], str(sandbox), {}) + elif form == "argv": + args = (None, [str(executable), "--version"], str(sandbox), {}) + else: + args = (None, f'"{executable}" --version', str(sandbox), {}) + guard("subprocess.Popen", args) + + +@pytest.mark.parametrize("form", ["relative", "inside-sandbox"]) +def test_integration_guard_still_rejects_unsafe_executable(tmp_path, form): + sandbox = tmp_path / "sandbox" + sandbox.mkdir() + guard = _RUNNER.IntegrationAudit(sandbox) + executable = "git" if form == "relative" else str(sandbox / "git.exe") + with pytest.raises(PermissionError): + guard("subprocess.Popen", (None, [executable], str(sandbox), {})) + with pytest.raises(PermissionError): + guard("socket.connect", ()) + + +@pytest.fixture +def layout(tmp_path): + sandbox = tmp_path / "sandbox" + outside = tmp_path / "outside" + sandbox.mkdir() + outside.mkdir() + return SandboxAudit(sandbox), sandbox, outside + + +def _symlink_or_skip(link, target, *, directory=False): + try: + link.symlink_to(target, target_is_directory=directory) + except NotImplementedError: + pytest.skip("OS does not implement symlinks") + except OSError as exc: + if (exc.errno in (errno.EPERM, errno.EACCES, errno.ENOSYS, errno.ENOTSUP) + or getattr(exc, "winerror", None) in (1, 50, 1314)): + pytest.skip("Symlink creation requires unavailable OS permissions") + raise + + +@pytest.mark.parametrize("operation", ["os.remove", "os.rmdir", "os.mkdir", "os.rename", "os.symlink"]) +def test_entry_operations_do_not_resolve_cyclic_leaf(layout, monkeypatch, operation): + guard, sandbox, _ = layout + leaf = sandbox / "loop" + original = Path.resolve + + def resolve(path, *args, **kwargs): + if path == leaf: + raise RuntimeError("Symlink loop from test leaf") + return original(path, *args, **kwargs) + + # Works even on Windows machines without permission to create real links. + monkeypatch.setattr(Path, "resolve", resolve) + args = { + "os.remove": (leaf, -1), "os.rmdir": (leaf, -1), + "os.mkdir": (leaf, 0o700, -1), + "os.rename": (leaf, sandbox / "renamed", -1, -1), + "os.symlink": ("arbitrary-link-target", leaf, -1), + }[operation] + guard(operation, args) + with pytest.raises(RuntimeError, match="Symlink loop"): + guard("open", (leaf, "w", os.O_WRONLY)) + + +@pytest.mark.parametrize("event", ["open", "os.chmod", "os.utime", "os.truncate", "os.link"]) +def test_content_operations_still_reject_external_leaf(layout, monkeypatch, event): + guard, sandbox, outside = layout + leaf = sandbox / "outward-link" + target = outside / "protected" + original = Path.resolve + + def resolve(path, *args, **kwargs): + return target if path == leaf else original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", resolve) + args = { + "open": (leaf, "w", os.O_WRONLY), + "os.chmod": (leaf, 0o600, -1), + "os.utime": (leaf, None, None, -1), + "os.truncate": (leaf, 0), + "os.link": (leaf, sandbox / "alias", -1, -1), + }[event] + with pytest.raises(PermissionError, match="outside"): + guard(event, args) + # Unlink affects only the in-sandbox directory entry, not the target. + guard("os.remove", (leaf, -1)) + + +@pytest.mark.parametrize("operation", ["os.remove", "os.rmdir", "os.mkdir", "os.rename", "os.symlink"]) +def test_entry_operations_reject_external_parent(layout, monkeypatch, operation): + guard, sandbox, outside = layout + parent = sandbox / "redirect" + original = Path.resolve + + def resolve(path, *args, **kwargs): + return outside if path == parent else original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", resolve) + leaf = parent / "protected" + args = { + "os.remove": (leaf, -1), "os.rmdir": (leaf, -1), + "os.mkdir": (leaf, 0o700, -1), + "os.rename": (sandbox / "inside", leaf, -1, -1), + "os.symlink": ("unused-target", leaf, -1), + }[operation] + with pytest.raises(PermissionError, match="outside"): + guard(operation, args) + + +def test_directory_traversal_is_rejected(layout): + guard, sandbox, _ = layout + for path in (sandbox / "..", sandbox / ".." / "protected"): + with pytest.raises(PermissionError, match="outside"): + guard("os.remove", (path, -1)) + + +@pytest.mark.parametrize("suffix", [os.sep, os.sep + ".", os.sep + ".."]) +def test_directory_operand_cannot_hide_final_symlink(layout, monkeypatch, suffix): + guard, sandbox, outside = layout + link = sandbox / "redirect" + original = Path.resolve + + def resolve(path, *args, **kwargs): + if path == link or path == link / "..": + return outside + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", resolve) + with pytest.raises(PermissionError, match="outside"): + guard("os.remove", (str(link) + suffix, -1)) + + +@pytest.mark.parametrize("source_external,destination_external", [(True, False), (False, True)]) +def test_rename_checks_both_directory_entries(layout, source_external, destination_external): + guard, sandbox, outside = layout + source = (outside if source_external else sandbox) / "source" + dest = (outside if destination_external else sandbox) / "dest" + with pytest.raises(PermissionError, match="outside"): + guard("os.rename", (source, dest, -1, -1)) + + +@pytest.mark.parametrize("event", ["subprocess.Popen", "os.system", "os.exec", "os.posix_spawn", + "os.spawn", "socket.connect", "socket.bind", "socket.getaddrinfo"]) +def test_process_and_network_access_remains_blocked(layout, event): + guard, _, _ = layout + with pytest.raises(PermissionError, match="processes and network"): + guard(event, ()) + + +@pytest.mark.parametrize("kind", ["loop", "dangling", "outward", "directory"]) +def test_real_fixture_links_can_be_cleaned_without_changing_targets(layout, kind): + guard, sandbox, outside = layout + target = outside / "protected.txt" + target.write_bytes(b"unchanged") + link = sandbox / "link" + destination = { + "loop": link, "dangling": outside / "absent", + "outward": target, "directory": outside, + }[kind] + _symlink_or_skip(link, destination, directory=kind == "directory") + guard("os.remove", (link, -1)) + # The runner's actual audit hook is also active for this unlink. + link.unlink() + assert not link.is_symlink() + assert target.read_bytes() == b"unchanged" + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux fd-relative rmtree path") +def test_fd_relative_unlink_uses_directory_handle_not_cwd(layout, monkeypatch): + guard, sandbox, outside = layout + link = sandbox / "loop" + _symlink_or_skip(link, link) + monkeypatch.chdir(outside) + descriptor = os.open(sandbox, os.O_RDONLY) + try: + guard("os.remove", ("loop", descriptor)) + with pytest.raises(PermissionError, match="outside"): + guard("os.remove", ("../outside/protected", descriptor)) + os.unlink("loop", dir_fd=descriptor) + finally: + os.close(descriptor) + + +def test_rmtree_cleans_cyclic_links_under_active_runner_guard(tmp_path): + root = tmp_path / "cleanup" + root.mkdir() + _symlink_or_skip(root / "loop", root / "loop") + shutil.rmtree(root) + assert not root.exists() diff --git a/CoderMind/tests/test_step3_polish.py b/CoderMind/tests/test_step3_polish.py index 215f264..0d1aa27 100644 --- a/CoderMind/tests/test_step3_polish.py +++ b/CoderMind/tests/test_step3_polish.py @@ -13,13 +13,14 @@ ``head_timestamp`` even in **noop** mode (covers ``git checkout other_branch_at_same_sha`` and ``git branch -m`` cases). -D. Git hook setup installs post-commit/post-merge dispatcher hooks and - removes CoderMind-owned pre-commit blocks. +D. Opt-in Git hook setup installs sync-only post-commit/post-merge + dispatchers and removes CoderMind-owned pre-commit blocks. """ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -216,9 +217,9 @@ def _run_status_text(repo: Path, rpg_path: Path, dep_graph_path: Path) -> str: """Run ``update_graphs.py status`` in text mode and return stdout.""" script = _project_root / "scripts" / "update_graphs.py" return subprocess.run( - [sys.executable, str(script), "status", + [sys.executable, "-X", "utf8", str(script), "status", "--rpg", str(rpg_path), "--dep-graph", str(dep_graph_path)], - cwd=repo, capture_output=True, text=True, + cwd=repo, capture_output=True, text=True, encoding="utf-8", check=True, ).stdout @@ -352,10 +353,11 @@ def test_install_post_merge_hook_writes_script(tmp_path): assert post_merge.is_file() content = post_merge.read_text() assert "CoderMind: post-merge dispatcher" in content - assert "cmind hook post-merge" in content + assert cmind_cli._cli_shell_command("hook", "post-merge") in content assert "--staged-only" not in content import stat - assert post_merge.stat().st_mode & stat.S_IXUSR + if os.name != "nt": # Windows stat does not expose POSIX executable bits. + assert post_merge.stat().st_mode & stat.S_IXUSR def test_install_post_merge_hook_is_idempotent(tmp_path): @@ -383,11 +385,11 @@ def test_install_post_merge_hook_preserves_existing_user_hook(tmp_path): content = user_hook.read_text() assert "echo 'user custom hook'" in content assert "CoderMind: post-merge dispatcher" in content - assert "cmind hook post-merge" in content + assert cmind_cli._cli_shell_command("hook", "post-merge") in content def test_install_hooks_installs_post_hooks_and_removes_pre_commit(tmp_path): - """End-to-end: ``_install_hooks`` writes post hooks and no pre-commit.""" + """Explicit opt-in writes post hooks and no pre-commit.""" project = tmp_path / "proj" project.mkdir() (project / ".cmind" / "scripts").mkdir(parents=True) @@ -395,7 +397,7 @@ def test_install_hooks_installs_post_hooks_and_removes_pre_commit(tmp_path): (project / ".cmind" / "scripts" / "update_graphs.py").write_text("") _sh(project, "init", "-q") - cmind_cli._install_hooks(project, "copilot", tracker=None) + cmind_cli._install_hooks(project, "copilot", tracker=None, git_hooks=True) pre_commit = project / ".git" / "hooks" / "pre-commit" post_commit = project / ".git" / "hooks" / "post-commit" @@ -418,7 +420,7 @@ def test_install_post_commit_hook_writes_script(tmp_path): assert post_commit.is_file() content = post_commit.read_text() assert "CoderMind: post-commit dispatcher" in content - assert "cmind hook post-commit" in content + assert cmind_cli._cli_shell_command("hook", "post-commit") in content assert "update_graphs.py" not in content assert "update-rpg" not in content assert "GIT_INDEX_FILE" not in content @@ -429,7 +431,8 @@ def test_install_post_commit_hook_writes_script(tmp_path): assert "-mmin +60" not in content assert "--staged-only" not in content import stat - assert post_commit.stat().st_mode & stat.S_IXUSR + if os.name != "nt": + assert post_commit.stat().st_mode & stat.S_IXUSR def test_install_post_commit_hook_is_idempotent(tmp_path): diff --git a/CoderMind/tests/test_windows_ai_cli.py b/CoderMind/tests/test_windows_ai_cli.py new file mode 100644 index 0000000..4e4cb66 --- /dev/null +++ b/CoderMind/tests/test_windows_ai_cli.py @@ -0,0 +1,538 @@ +"""Inert filesystem fixtures only: never execute a CLI, wrapper, or Node. + +Load the standalone module directly to avoid common.__init__ and policy imports. +The Windows resolver itself is platform-independent, so these tests also cover +its path/metadata contract on Linux. Only unavailable symlink creation skips. +""" + +import errno +import importlib.util +import json +import os +from pathlib import Path, PureWindowsPath +import subprocess + +import pytest + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts/common/windows_ai_cli.py" +SPEC = importlib.util.spec_from_file_location("windows_ai_cli_under_test", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +windows_ai_cli = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(windows_ai_cli) +resolve_windows_argv = windows_ai_cli.resolve_windows_argv + +PACKAGE_NAMES = { + "claude": "@anthropic-ai/claude-code", + "copilot": "@github/copilot", +} +OFFICIAL_LAYOUTS = [ + ("claude", "2.1.278", "bin/claude.exe"), + ("claude", "2.0.0", "cli.js"), + ("copilot", "1.0.87", "npm-loader.js"), + ("copilot", "0.0.369", "index.js"), +] +DIRECT_PROVIDERS = [ + ("claude", "claude"), ("copilot", "copilot"), ("gemini", "gemini"), + ("qwen", "qwen"), ("cursor-agent", "agent"), ("auggie", "augment"), + ("codex", "codex"), ("codebuddy", "codebuddy"), ("qoder", "qodercli"), + ("opencode", "opencode"), ("amp", "amp"), +] +INERT = b"INERT TEST FIXTURE - NOT AN EXECUTABLE OR SCRIPT\n" + + +@pytest.fixture(autouse=True) +def forbid_processes(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("Process execution is forbidden in resolver tests") + + monkeypatch.setattr(subprocess, "Popen", forbidden) + monkeypatch.setattr(subprocess, "run", forbidden) + monkeypatch.setattr(os, "system", forbidden) + if hasattr(os, "startfile"): + monkeypatch.setattr(os, "startfile", forbidden) + + +@pytest.fixture +def layout(tmp_path, monkeypatch): + # Normalize Windows temporary-directory short names before comparisons. + root = tmp_path.resolve() + tools = root / "trusted tools 工具" + workspace = root / "workspace" + cwd = root / "current directory" + for directory in (tools, workspace, cwd): + directory.mkdir() + monkeypatch.chdir(cwd) + return tools, (workspace, cwd) + + +def inert_file(path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(INERT) + return path.resolve() + + +def write_manifest(package, metadata): + manifest = package / "package.json" + manifest.write_text(json.dumps(metadata), encoding="utf-8") + return manifest + + +def make_package(directory, provider="claude", entry="cli.js", version="2.0.0"): + package = directory / "node_modules" / PACKAGE_NAMES[provider] + package.mkdir(parents=True, exist_ok=True) + entry_path = inert_file(package / entry) + write_manifest(package, { + "name": PACKAGE_NAMES[provider], "version": version, "bin": {provider: entry}, + }) + return package, entry_path + + +def symlink(link, target, *, directory=False): + try: + link.symlink_to(target, target_is_directory=directory) + except NotImplementedError as exc: + pytest.skip(f"Symlink creation unavailable: {exc}") + except OSError as exc: + unavailable = {errno.EACCES, errno.EPERM, errno.ENOSYS, errno.ENOTSUP} + if exc.errno in unavailable or getattr(exc, "winerror", None) in {1, 50, 1314}: + pytest.skip(f"Symlink creation unavailable: {exc}") + raise + + +@pytest.mark.parametrize("provider,version,name", OFFICIAL_LAYOUTS) +def test_official_layouts_return_only_executable_and_fixed_entry(layout, provider, version, name): + tools, roots = layout + _, entry = make_package(tools, provider, name, version) + expected = [str(entry)] + if name.endswith(".js"): + node = inert_file(tools / "node.exe") + expected.insert(0, str(node)) + directories = [tools] + assert resolve_windows_argv(provider, directories, roots) == expected + assert directories == [tools] + assert all(Path(arg).is_absolute() for arg in expected) + + +@pytest.mark.parametrize("provider,version,name", OFFICIAL_LAYOUTS) +def test_build_argv_reaches_npm_adapter_end_to_end(layout, monkeypatch, provider, version, name): + # Load the real runtime policy (preloaded from the wheel in artifact mode). + from common import ai_cli_policy as policy + + tools, roots = layout + _, entry = make_package(tools, provider, name, version) + expected = [str(entry)] + if name.endswith(".js"): + expected.insert(0, str(inert_file(tools / "node.exe"))) + # The input shim can contain anything: it is never read or launched. + inert_file(tools / (provider + ".cmd")) + monkeypatch.setattr(policy, "_IS_WINDOWS", True) + assert policy.build_argv(provider, roots[0], search_path=str(tools)) == expected + + +def test_build_argv_prefers_direct_native_over_npm_in_earlier_path(layout, monkeypatch): + from common import ai_cli_policy as policy + + tools, roots = layout + make_package(tools) + inert_file(tools / "node.exe") + native = inert_file(tools.parent / "native" / "claude.exe") + native.chmod(0o755) + monkeypatch.setattr(policy, "_IS_WINDOWS", True) + assert policy.build_argv("claude", roots[0], search_path=os.pathsep.join([ + str(tools), str(native.parent), + ])) == [str(native)] + + +def test_build_argv_does_not_follow_exe_link_to_batch_file(layout, monkeypatch): + from common import ai_cli_policy as policy + + tools, roots = layout + target = inert_file(tools / "wrapper.cmd") + target.chmod(0o755) + symlink(tools / "claude.exe", target) + monkeypatch.setattr(policy, "_IS_WINDOWS", True) + with pytest.raises(policy.AICommandPolicyError): + policy.build_argv("claude", roots[0], search_path=str(tools)) + + +@pytest.mark.parametrize("provider,base", DIRECT_PROVIDERS) +def test_direct_native_resolution_belongs_to_caller(layout, provider, base): + tools, roots = layout + inert_file(tools / (base + ".exe")) + assert resolve_windows_argv(provider, [tools], roots) is None + + +@pytest.mark.parametrize("provider", ["", "CLAUDE", "claude -p", "claude.exe", "copilot.cmd", None, [], {}]) +def test_invalid_provider_does_not_select_an_adapter(layout, provider): + tools, roots = layout + make_package(tools, entry="bin/claude.exe") + assert resolve_windows_argv(provider, [tools], roots) is None + + +@pytest.mark.parametrize("provider", ["claude", "copilot"]) +@pytest.mark.parametrize("suffix", [".cmd", ".bat", ".ps1"]) +def test_wrappers_alone_are_never_read_or_selected(layout, monkeypatch, provider, suffix): + tools, roots = layout + inert_file(tools / (provider + suffix)) + inert_file(tools / "node.exe") + + def no_read(*args, **kwargs): + raise AssertionError("No package manifest exists; no file should be read") + + monkeypatch.setattr(Path, "open", no_read) + assert resolve_windows_argv(provider, [tools], roots) is None + + +def test_valid_package_ignores_all_wrappers(layout, monkeypatch): + tools, roots = layout + _, entry = make_package(tools) + node = inert_file(tools / "node.exe") + for base in ("claude", "node", "npm", "npx"): + for suffix in (".cmd", ".bat", ".ps1"): + inert_file(tools / (base + suffix)) + original_open = Path.open + + def checked_open(path, *args, **kwargs): + assert path.suffix not in {".cmd", ".bat", ".ps1"} + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", checked_open) + assert resolve_windows_argv("claude", [tools], roots) == [str(node), str(entry)] + + +@pytest.mark.parametrize("name", ["cli.js", "bin/claude.exe"]) +@pytest.mark.parametrize("metadata", [ + None, [], "package", 7, True, {}, + {"bin": {"claude": "cli.js"}}, + {"name": "@github/copilot", "bin": {"claude": "cli.js"}}, + {"name": "@Anthropic-ai/claude-code", "bin": {"claude": "cli.js"}}, + {"name": "@anthropic-ai/claude-code ", "bin": {"claude": "cli.js"}}, + {"name": [], "bin": {"claude": "cli.js"}}, + {"name": "@anthropic-ai/claude-code"}, +]) +def test_rejects_wrong_package_identity_and_metadata_shape(layout, name, metadata): + tools, roots = layout + package, _ = make_package(tools, entry=name) + inert_file(tools / "node.exe") + write_manifest(package, metadata) + assert resolve_windows_argv("claude", [tools], roots) is None + + +@pytest.mark.parametrize("bins", [ + None, "cli.js", [], True, 9, {}, + {"other": "cli.js"}, {"Claude": "cli.js"}, + {"claude": "cli.js", "extra": "cli.js"}, + {"claude": None}, {"claude": True}, {"claude": 9}, + {"claude": ["cli.js"]}, {"claude": {"path": "cli.js"}}, +]) +def test_rejects_nonexact_bin_key_and_value_types(layout, bins): + tools, roots = layout + package, _ = make_package(tools) + inert_file(tools / "node.exe") + write_manifest(package, {"name": PACKAGE_NAMES["claude"], "bin": bins}) + assert resolve_windows_argv("claude", [tools], roots) is None + + +@pytest.mark.parametrize("provider,version,name", OFFICIAL_LAYOUTS) +@pytest.mark.parametrize("bad_entry", [ + "", "other.js", "claude.exe", "copilot.exe", "./cli.js", "../cli.js", + "bin/../cli.js", "/cli.js", "cli.js/", "CLI.JS", "cli.js ", + "cli.js --settings bad", '"cli.js"', "cli.js;ignored", "cli.js\nignored", + "claude.cmd", "claude.bat", "claude.ps1", "cli.js:payload", "cli.js\x00", + "https://example.invalid/cli.js", "file:///cli.js", "bin//claude.exe", + r"C:\tools\cli.js", r"C:cli.js", r"..\cli.js", r"bin\claude.exe", + r"\\server\share\cli.js", "bin/claude.exe --extra", +]) +def test_rejects_paths_arguments_and_nonallowlisted_entries(layout, provider, version, name, bad_entry): + tools, roots = layout + package, _ = make_package(tools, provider, name, version) + inert_file(tools / "node.exe") + write_manifest(package, {"name": PACKAGE_NAMES[provider], "bin": {provider: bad_entry}}) + assert resolve_windows_argv(provider, [tools], roots) is None + + +@pytest.mark.parametrize("raw", [ + b"", b"{", b"\xff", b'\xef\xbb\xbf{}', + b'{"name":"@anthropic-ai/claude-code","bin":{"claude":"cli.js"},}', + b'{"name":"wrong","name":"@anthropic-ai/claude-code","bin":{"claude":"cli.js"}}', + b'{"name":"@anthropic-ai/claude-code","bin":{},"bin":{"claude":"cli.js"}}', + b'{"name":"@anthropic-ai/claude-code","bin":{"claude":"other.js","claude":"cli.js"}}', + b'{"name":"@anthropic-ai/claude-code","bin":{"claude":"cli.js"},"extra":NaN}', + b'{"name":"@anthropic-ai/claude-code","bin":{"claude":"cli.js"},"extra":Infinity}', + b"[" * 2000 + b"]" * 2000, + b" " * (64 * 1024 + 1), +], ids=["empty", "truncated", "invalid-utf8", "bom", "trailing-comma", + "duplicate-name", "duplicate-bin", "duplicate-entry", "nan", "infinity", + "deep-json", "oversized"]) +def test_malformed_manifest_is_a_miss_not_an_exception(layout, raw): + tools, roots = layout + package, _ = make_package(tools) + inert_file(tools / "node.exe") + (package / "package.json").write_bytes(raw) + assert resolve_windows_argv("claude", [tools], roots) is None + + +def test_evidence_versions_are_not_pins_and_other_metadata_is_not_executed(layout): + tools, roots = layout + package, entry = make_package(tools, entry="bin/claude.exe", version="99.0.0") + write_manifest(package, { + "name": PACKAGE_NAMES["claude"], "version": "99.0.0", + "bin": {"claude": "bin/claude.exe"}, + "scripts": {"postinstall": "INERT PLACEHOLDER"}, + "main": "ignored.js", "exports": "ignored.js", + }) + assert resolve_windows_argv("claude", [tools], roots) == [str(entry)] + + +@pytest.mark.parametrize("provider,version,name", OFFICIAL_LAYOUTS) +@pytest.mark.parametrize("broken", ["manifest_missing", "manifest_directory", "entry_missing", "entry_directory"]) +def test_manifest_and_entry_must_be_existing_files(layout, provider, version, name, broken): + tools, roots = layout + package, entry = make_package(tools, provider, name, version) + inert_file(tools / "node.exe") + target = package / "package.json" if broken.startswith("manifest") else entry + target.unlink() + if broken.endswith("directory"): + target.mkdir() + assert resolve_windows_argv(provider, [tools], roots) is None + + +@pytest.mark.parametrize("provider,version,name", OFFICIAL_LAYOUTS) +def test_native_needs_no_node_but_javascript_does(layout, provider, version, name): + tools, roots = layout + _, entry = make_package(tools, provider, name, version) + expected = [str(entry)] if name.endswith(".exe") else None + assert resolve_windows_argv(provider, [tools], roots) == expected + + +def test_node_is_found_in_another_supplied_directory(layout): + tools, roots = layout + _, entry = make_package(tools, "copilot", "npm-loader.js", "1.0.87") + node = inert_file(tools.parent / "node installation" / "node.exe") + assert resolve_windows_argv("copilot", [tools, node.parent], roots) == [str(node), str(entry)] + + +def test_never_searches_environment_cwd_or_prefix_parent_for_node(layout, monkeypatch): + tools, roots = layout + make_package(tools) + external = inert_file(tools.parent / "not supplied" / "node.exe") + inert_file(tools.parent / "node.exe") + for root in roots: + inert_file(root / "node.exe") + monkeypatch.setenv("PATH", str(external.parent)) + monkeypatch.setenv("PATHEXT", ".CMD;.BAT;.PS1;.EXE") + assert resolve_windows_argv("claude", [tools], roots) is None + + +@pytest.mark.parametrize("name", ["node", "node.cmd", "node.bat", "node.ps1"]) +def test_node_wrappers_or_extensionless_files_are_not_interpreters(layout, name): + tools, roots = layout + make_package(tools) + inert_file(tools / name) + assert resolve_windows_argv("claude", [tools], roots) is None + + +def test_node_directory_is_not_an_interpreter(layout): + tools, roots = layout + make_package(tools) + (tools / "node.exe").mkdir() + assert resolve_windows_argv("claude", [tools], roots) is None + + +def test_unsafe_directories_and_invalid_candidates_do_not_hide_later_install(layout): + tools, roots = layout + for root in roots: + make_package(root, entry="bin/claude.exe") + make_package(root / "bin", entry="bin/claude.exe") + broken = tools.parent / "broken" + package, _ = make_package(broken, entry="bin/claude.exe") + write_manifest(package, {"name": "wrong", "bin": {"claude": "bin/claude.exe"}}) + wrappers = tools.parent / "wrappers" + inert_file(wrappers / "claude.cmd") + _, entry = make_package(tools, entry="bin/claude.exe") + regular_file = inert_file(tools.parent / "not a directory") + directories = [ + Path(""), Path("."), Path("bin"), *roots, *(root / "bin" for root in roots), + tools.parent / "missing", regular_file, wrappers, broken, tools, + ] + assert resolve_windows_argv("claude", directories, roots) == [str(entry)] + + +def test_valid_package_search_preserves_directory_order(layout): + tools, roots = layout + _, first = make_package(tools, entry="bin/claude.exe") + other = tools.parent / "other" + _, second = make_package(other, entry="bin/claude.exe") + assert resolve_windows_argv("claude", [other, tools], roots) == [str(second)] + assert resolve_windows_argv("claude", [tools, other], roots) == [str(first)] + + +def test_missing_node_in_earlier_js_install_does_not_hide_later_native(layout): + tools, roots = layout + make_package(tools) + other = tools.parent / "native" + _, entry = make_package(other, entry="bin/claude.exe") + assert resolve_windows_argv("claude", [tools, other], roots) == [str(entry)] + + +@pytest.mark.parametrize("root_index", [0, 1]) +@pytest.mark.parametrize("component", ["prefix", "package", "manifest", "entry", "node"]) +def test_links_into_workspace_or_cwd_are_rejected(layout, root_index, component): + tools, roots = layout + excluded = roots[root_index] + if component == "prefix": + make_package(excluded, entry="bin/claude.exe") + link = tools / "prefix-link" + symlink(link, excluded, directory=True) + directories = [link] + elif component == "package": + target, _ = make_package(excluded, entry="bin/claude.exe") + link = tools / "node_modules" / PACKAGE_NAMES["claude"] + link.parent.mkdir(parents=True) + symlink(link, target, directory=True) + directories = [tools] + else: + package, entry = make_package(tools) + if component == "node": + target = inert_file(excluded / "node.exe") + symlink(tools / "node.exe", target) + else: + inert_file(tools / "node.exe") + link = package / "package.json" if component == "manifest" else entry + target = excluded / link.name + target.write_bytes(link.read_bytes()) + link.unlink() + symlink(link, target) + directories = [tools] + assert resolve_windows_argv("claude", directories, roots) is None + + +@pytest.mark.parametrize("component", ["manifest", "entry"]) +@pytest.mark.parametrize("provider,version,name", OFFICIAL_LAYOUTS) +def test_manifest_and_entry_links_may_not_escape_package_even_to_external_tools( + layout, component, provider, version, name +): + tools, roots = layout + package, entry = make_package(tools, provider, name, version) + inert_file(tools / "node.exe") + link = package / "package.json" if component == "manifest" else entry + # In particular, a Claude native link into a sibling platform package is + # unsupported without a separately verified family/version/architecture rule. + target = package.parent / "sibling-platform-package" / link.name + target.parent.mkdir() + target.write_bytes(link.read_bytes()) + link.unlink() + symlink(link, target) + assert resolve_windows_argv(provider, [tools], roots) is None + + +def test_package_link_to_external_install_is_canonicalized(layout): + tools, roots = layout + target, entry = make_package(tools.parent / "external install", entry="bin/claude.exe") + link = tools / "node_modules" / PACKAGE_NAMES["claude"] + link.parent.mkdir(parents=True) + symlink(link, target, directory=True) + assert resolve_windows_argv("claude", [tools], roots) == [str(entry)] + + +def test_lexically_excluded_prefix_link_to_external_tools_is_rejected(layout): + tools, roots = layout + make_package(tools, entry="bin/claude.exe") + link = roots[0] / "external-tools" + symlink(link, tools, directory=True) + assert resolve_windows_argv("claude", [link], roots) is None + + +def test_excluded_root_alias_also_excludes_its_canonical_destination(layout): + tools, roots = layout + make_package(tools, entry="bin/claude.exe") + alias = roots[0] / "tools-alias" + symlink(alias, tools, directory=True) + assert resolve_windows_argv("claude", [tools], (alias,)) is None + + +def test_invalid_relative_exclusion_fails_closed(layout): + tools, _ = layout + make_package(tools, entry="bin/claude.exe") + assert resolve_windows_argv("claude", [tools], (Path("workspace"),)) is None + + +def test_unsafe_node_link_is_skipped_in_favor_of_later_safe_node(layout): + tools, roots = layout + _, entry = make_package(tools) + local_node = inert_file(roots[0] / "node.exe") + symlink(tools / "node.exe", local_node) + node = inert_file(tools.parent / "safe node" / "node.exe") + assert resolve_windows_argv("claude", [tools, node.parent], roots) == [str(node), str(entry)] + + +@pytest.mark.parametrize("component", ["node", "native_entry"]) +@pytest.mark.parametrize("suffix", [".cmd", ".bat", ".ps1"]) +def test_exe_links_cannot_change_returned_command_into_wrapper(layout, component, suffix): + tools, roots = layout + if component == "native_entry": + package, link = make_package(tools, entry="bin/claude.exe") + target = inert_file(package / ("payload" + suffix)) + link.unlink() + else: + make_package(tools) + target = inert_file(tools / ("payload" + suffix)) + link = tools / "node.exe" + symlink(link, target) + assert resolve_windows_argv("claude", [tools], roots) is None + + +@pytest.mark.parametrize("kind", ["broken", "loop"]) +def test_broken_or_looping_entry_links_are_misses(layout, kind): + tools, roots = layout + _, entry = make_package(tools, entry="bin/claude.exe") + entry.unlink() + symlink(entry, entry if kind == "loop" else entry.parent / "missing.exe") + assert resolve_windows_argv("claude", [tools], roots) is None + + +@pytest.mark.parametrize("operation", ["resolve", "open"]) +def test_filesystem_errors_skip_candidate_without_raising(layout, monkeypatch, operation): + tools, roots = layout + package, _ = make_package(tools, entry="bin/claude.exe") + other = tools.parent / "fallback" + _, entry = make_package(other, entry="bin/claude.exe") + original = getattr(Path, operation) + blocked = package if operation == "resolve" else package / "package.json" + + def denied(path, *args, **kwargs): + if path == blocked: + raise PermissionError("synthetic unreadable installation") + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, operation, denied) + assert resolve_windows_argv("claude", [tools, other], roots) == [str(entry)] + + +def test_unresolvable_exclusion_fails_closed(layout, monkeypatch): + tools, roots = layout + make_package(tools, entry="bin/claude.exe") + original_resolve = Path.resolve + + def denied(path, *args, **kwargs): + if path == roots[0]: + raise OSError("synthetic exclusion failure") + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", denied) + assert resolve_windows_argv("claude", [tools], roots) is None + + +@pytest.mark.parametrize("candidate,parent,expected", [ + (r"\\?\C:\workspace\node.exe", r"C:\workspace", True), + (r"C:\workspace\node.exe", r"\\?\C:\workspace", True), + (r"\\?\C:\WORKSPACE\node.exe", r"c:\workspace", True), + (r"\\?\UNC\server\share\workspace\node.exe", r"\\server\share\workspace", True), + (r"\\server\share\workspace\node.exe", r"\\?\UNC\server\share\workspace", True), + (r"\\?\C:\workspace-other\node.exe", r"C:\workspace", False), + (r"\\?\UNC\server\other\workspace\node.exe", r"\\server\share\workspace", False), +]) +def test_windows_extended_path_containment_on_every_os(candidate, parent, expected): + assert windows_ai_cli._is_within(PureWindowsPath(candidate), PureWindowsPath(parent)) is expected + diff --git a/CoderMind/tests/verify_release_security.py b/CoderMind/tests/verify_release_security.py new file mode 100644 index 0000000..b90d5dd --- /dev/null +++ b/CoderMind/tests/verify_release_security.py @@ -0,0 +1,103 @@ +"""CI-only, read-only verification of all 22 provider/script release ZIPs.""" + +import ast +from collections import Counter +import importlib.util +from pathlib import Path, PurePosixPath +import sys +import zipfile + + +SCRIPT_TYPES = ("sh", "ps") +SCRIPT_PREFIX = ".cmind/scripts/" + + +def expected_script_sources(scripts_root: Path) -> dict[str, str]: + """Discover every reviewed Python script, including future security helpers.""" + sources = {} + for path in sorted(scripts_root.rglob("*.py")): + relative = path.relative_to(scripts_root) + if path.is_file() and "__pycache__" not in relative.parts: + # Accept only the BOM/CRLF differences allowed for packaged scripts. + sources[relative.as_posix()] = path.read_bytes().decode("utf-8-sig").replace("\r\n", "\n") + if not sources: + raise RuntimeError("No source Python scripts found for release verification") + return sources + + +def verify_archive(path: Path, sources: dict[str, str], command: str) -> None: + expected = { + SCRIPT_PREFIX + name: text.replace("", command) + for name, text in sources.items() + } + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + # Check before reading by name: ZipFile.read otherwise hides duplicates. + duplicates = sorted(name for name, count in Counter( + member.filename for member in members + ).items() if count != 1) + if duplicates: + raise RuntimeError(f"{path.name}: Duplicate ZIP members: {duplicates}") + actual_names = { + member.filename for member in members + if not member.is_dir() and member.filename.startswith(SCRIPT_PREFIX) + and member.filename.endswith(".py") + and "__pycache__" not in PurePosixPath(member.filename).parts + } + missing = sorted(expected.keys() - actual_names) + unexpected = sorted(actual_names - expected.keys()) + if missing or unexpected: + raise RuntimeError( + f"{path.name}: Python script catalog mismatch; " + f"missing={missing}, unexpected={unexpected}" + ) + for name, text in expected.items(): + try: + actual = archive.read(name).decode("utf-8-sig").replace("\r\n", "\n") + if actual != text: + raise RuntimeError(f"{path.name}: Release ZIP has stale or altered {name}") + # Parse packaged text, without importing or executing any script. + ast.parse(actual, filename=f"{path.name}:{name}") + except (UnicodeDecodeError, SyntaxError) as exc: + raise RuntimeError(f"{path.name}: Invalid Python script {name}") from exc + + +def main() -> None: + root = Path(__file__).resolve().parents[1] + policy_path = root / "scripts/common/ai_cli_policy.py" + spec = importlib.util.spec_from_file_location("release_policy", policy_path) + if spec is None or spec.loader is None: + raise RuntimeError("Missing provider policy") + policy = importlib.util.module_from_spec(spec) + spec.loader.exec_module(policy) + if len(policy.PROVIDER_ARGV) != 11: + raise RuntimeError("Expected the closed catalog of 11 release providers") + archives = Path(sys.argv[1]) + archive_paths = {path for path in archives.iterdir() if path.suffix.lower() == ".zip"} + variants = {} + for provider, argv in policy.PROVIDER_ARGV.items(): + command = " ".join(argv) + if policy.provider_from_command(command) != provider: + raise RuntimeError("Release default does not satisfy the closed policy") + for script_type in SCRIPT_TYPES: + prefix = f"cmind-template-{provider}-{script_type}-" + matches = sorted(path for path in archive_paths if path.name.startswith(prefix)) + if len(matches) != 1: + raise RuntimeError( + f"Expected exactly one {script_type} release ZIP for {provider}; found {len(matches)}" + ) + variants[matches[0]] = command + unexpected = sorted(path.name for path in archive_paths - variants.keys()) + if len(archive_paths) != 22 or unexpected: + raise RuntimeError(f"Expected exactly 22 release ZIPs; unexpected ZIPs: {unexpected}") + sources = expected_script_sources(root / "scripts") + for path, command in variants.items(): + verify_archive(path, sources, command) + print( + f"Verified all {len(sources)} Python scripts against reviewed source in all 22 release ZIPs " + "(11 providers x sh/ps), including provider substitutions and AST syntax" + ) + + +if __name__ == "__main__": + main() diff --git a/README.md b/README.md index be111a9..5aba548 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cmind check ```bash cd your-existing-repo -cmind init . --encode +cmind init . --ai claude --encode # In Claude Code or GitHub Copilot: # /cmind.rpg_edit "Add rate limiting to all API endpoints" ``` @@ -92,13 +92,15 @@ cmind init . --encode **Generate a new repository:** ```bash -cmind init my-project +cmind init my-project --ai claude cd my-project # In Claude Code or GitHub Copilot: # /cmind.feature_spec Build a CLI tool for managing Docker containers # /cmind.feature_build → /cmind.feature_refactor → ... → /cmind.code_gen ``` +Use `--ai copilot` instead for Copilot. Init saves the user's explicit provider selection outside the repository after successful setup. Interactive init can ask for the choice; noninteractive init requires `--ai`. Tracked provider recommendations and legacy configuration values do not authorize AI execution. On an existing workspace, `cmind update --ai claude --no-upgrade` explicitly establishes or changes the local choice after installing the updated CLI; an update without `--ai` does not grant one automatically. See the [execution configuration and migration guide](CoderMind/docs/configuration.md#updating-an-existing-codermind-project). + See [`CoderMind/README.md`](CoderMind/README.md) for the full setup, slash commands, and MCP tools. Also available in [简体中文](CoderMind/README.zh-CN.md) · [日本語](CoderMind/README.ja-JP.md) · [한국어](CoderMind/README.ko-KR.md) · [हिन्दी](CoderMind/README.hi-IN.md). @@ -112,7 +114,7 @@ CoderMind exposes the RPG workspace through three interfaces: - **Slash commands** — run build, understand, and update workflows inside the coding agent (`/cmind.feature_spec`, `/cmind.code_gen`, `/cmind.encode`, `/cmind.rpg_edit`, and more). - **MCP graph tools** — let the agent search, inspect, and traverse RPG nodes during coding (`search_rpg`, `explore_rpg`, `get_node_detail`, `list_rpg_tree`). -CoderMind can keep the RPG in sync with code changes through a post-commit hook, so edits made by the agent or directly in code can be reflected back into the graph. +Git hooks are off by default. Passing `--git-hooks` to init/update installs foreground deterministic synchronization only; otherwise recognized CoderMind hook blocks are removed while unrelated hooks are preserved. Hooks never launch background AI updates. Run `/cmind.update_rpg` explicitly for an AI-driven incremental update, or `/cmind.encode` for a full rebuild. Normal provider permission checks still apply. **Supported agents:** Claude Code (verified), GitHub Copilot (verified).