diff --git a/.agents/skills/comment-and-doc-style/SKILL.md b/.agents/skills/comment-and-doc-style/SKILL.md index e753cd03..10a0eb7a 100644 --- a/.agents/skills/comment-and-doc-style/SKILL.md +++ b/.agents/skills/comment-and-doc-style/SKILL.md @@ -55,6 +55,24 @@ Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.N for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading anchor, a file extension. +## Docker lint authorization + +A restricted executor treats Docker socket access, image fetching, and repository exposure as +separate permissions. Repository exposure needs explicit maintainer approval even when the mount +is read-only. Request approval for the repository-standard lint shape: pull the image first, +resolve the pulled digest, mount the checkout read-only, and disable container networking. Persist +it only when the executor constrains that whole shape. Never allow an unconstrained `docker run` +prefix. PSScriptAnalyzer downloads its pinned module in a separate container that has network +access and no repository mount. `GOVERNANCE.md` "Running the Linters Locally (Known-Working +Invocations)" owns the exact commands and full authorization model. + +Agent-specific authorization stays in provider-labeled bullets so one agent's configuration does +not read as a shared requirement: + +- **Codex:** rules cannot safely cover changing worktree paths and digests. Smart Approvals can + prompt per task. No-prompt operation is supported only inside an external sandbox because it + removes command-wide protection. + ## Markdown formatting - **Reference-style links everywhere**, except the four files read one section at a time rather diff --git a/.agents/skills/repo-worktree/SKILL.md b/.agents/skills/repo-worktree/SKILL.md index 27e2e69a..1d60f3a1 100644 --- a/.agents/skills/repo-worktree/SKILL.md +++ b/.agents/skills/repo-worktree/SKILL.md @@ -161,11 +161,19 @@ A machine not yet migrated to this layout still isolates exactly the same way, s is the isolation rather than the path: create the worktree beside whatever layout the machine has, and note that the base clone may live elsewhere than `~/repos/`. -Claude Code's own `EnterWorktree` tool acts only on an explicit instruction from the user or the -project instructions, which is why the carried rules state this mandate in so many words. Given -a `name`, it creates the worktree under `.claude/worktrees/` inside the repo and bases it on the -GitHub default branch, which is the wrong path and the wrong base here. Create the worktree with -`git worktree add` as above, then attach with `EnterWorktree` `path:`, not `name:`. +## Agent-Specific Worktree Tools + +Provider-specific mechanics stay separate from the general creation procedure above: + +- **Claude Code:** its `EnterWorktree` tool acts only on an explicit instruction from the user or + project instructions. Given a `name`, it creates the worktree under `.claude/worktrees/` and + bases it on the GitHub default branch. Both differ from the fleet path and base. Create the + worktree with `git worktree add`, then attach with `EnterWorktree` `path:`, not `name:`. +- **Codex:** no provider-specific creation override applies. Use the general `git worktree add` + procedure above. Its host-specific writable-root setting lives in `docs/host-setup.md` "Agent + Worktree Access". +- **opencode:** no provider-specific creation override applies. Use the general + `git worktree add` procedure above. ## Preparing Git Hooks diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 69d7199d..eb5b2b09 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -74a2d7f8def278aa +33837ae6c41ef01f diff --git a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md index e753cd03..10a0eb7a 100644 --- a/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/comment-and-doc-style/SKILL.md @@ -55,6 +55,24 @@ Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.N for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading anchor, a file extension. +## Docker lint authorization + +A restricted executor treats Docker socket access, image fetching, and repository exposure as +separate permissions. Repository exposure needs explicit maintainer approval even when the mount +is read-only. Request approval for the repository-standard lint shape: pull the image first, +resolve the pulled digest, mount the checkout read-only, and disable container networking. Persist +it only when the executor constrains that whole shape. Never allow an unconstrained `docker run` +prefix. PSScriptAnalyzer downloads its pinned module in a separate container that has network +access and no repository mount. `GOVERNANCE.md` "Running the Linters Locally (Known-Working +Invocations)" owns the exact commands and full authorization model. + +Agent-specific authorization stays in provider-labeled bullets so one agent's configuration does +not read as a shared requirement: + +- **Codex:** rules cannot safely cover changing worktree paths and digests. Smart Approvals can + prompt per task. No-prompt operation is supported only inside an external sandbox because it + removes command-wide protection. + ## Markdown formatting - **Reference-style links everywhere**, except the four files read one section at a time rather diff --git a/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md b/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md index 27e2e69a..1d60f3a1 100644 --- a/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/repo-worktree/SKILL.md @@ -161,11 +161,19 @@ A machine not yet migrated to this layout still isolates exactly the same way, s is the isolation rather than the path: create the worktree beside whatever layout the machine has, and note that the base clone may live elsewhere than `~/repos/`. -Claude Code's own `EnterWorktree` tool acts only on an explicit instruction from the user or the -project instructions, which is why the carried rules state this mandate in so many words. Given -a `name`, it creates the worktree under `.claude/worktrees/` inside the repo and bases it on the -GitHub default branch, which is the wrong path and the wrong base here. Create the worktree with -`git worktree add` as above, then attach with `EnterWorktree` `path:`, not `name:`. +## Agent-Specific Worktree Tools + +Provider-specific mechanics stay separate from the general creation procedure above: + +- **Claude Code:** its `EnterWorktree` tool acts only on an explicit instruction from the user or + project instructions. Given a `name`, it creates the worktree under `.claude/worktrees/` and + bases it on the GitHub default branch. Both differ from the fleet path and base. Create the + worktree with `git worktree add`, then attach with `EnterWorktree` `path:`, not `name:`. +- **Codex:** no provider-specific creation override applies. Use the general `git worktree add` + procedure above. Its host-specific writable-root setting lives in `docs/host-setup.md` "Agent + Worktree Access". +- **opencode:** no provider-specific creation override applies. Use the general + `git worktree add` procedure above. ## Preparing Git Hooks diff --git a/.github/skills/comment-and-doc-style/SKILL.md b/.github/skills/comment-and-doc-style/SKILL.md index e753cd03..10a0eb7a 100644 --- a/.github/skills/comment-and-doc-style/SKILL.md +++ b/.github/skills/comment-and-doc-style/SKILL.md @@ -55,6 +55,24 @@ Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.N for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading anchor, a file extension. +## Docker lint authorization + +A restricted executor treats Docker socket access, image fetching, and repository exposure as +separate permissions. Repository exposure needs explicit maintainer approval even when the mount +is read-only. Request approval for the repository-standard lint shape: pull the image first, +resolve the pulled digest, mount the checkout read-only, and disable container networking. Persist +it only when the executor constrains that whole shape. Never allow an unconstrained `docker run` +prefix. PSScriptAnalyzer downloads its pinned module in a separate container that has network +access and no repository mount. `GOVERNANCE.md` "Running the Linters Locally (Known-Working +Invocations)" owns the exact commands and full authorization model. + +Agent-specific authorization stays in provider-labeled bullets so one agent's configuration does +not read as a shared requirement: + +- **Codex:** rules cannot safely cover changing worktree paths and digests. Smart Approvals can + prompt per task. No-prompt operation is supported only inside an external sandbox because it + removes command-wide protection. + ## Markdown formatting - **Reference-style links everywhere**, except the four files read one section at a time rather diff --git a/.github/skills/repo-worktree/SKILL.md b/.github/skills/repo-worktree/SKILL.md index 27e2e69a..1d60f3a1 100644 --- a/.github/skills/repo-worktree/SKILL.md +++ b/.github/skills/repo-worktree/SKILL.md @@ -161,11 +161,19 @@ A machine not yet migrated to this layout still isolates exactly the same way, s is the isolation rather than the path: create the worktree beside whatever layout the machine has, and note that the base clone may live elsewhere than `~/repos/`. -Claude Code's own `EnterWorktree` tool acts only on an explicit instruction from the user or the -project instructions, which is why the carried rules state this mandate in so many words. Given -a `name`, it creates the worktree under `.claude/worktrees/` inside the repo and bases it on the -GitHub default branch, which is the wrong path and the wrong base here. Create the worktree with -`git worktree add` as above, then attach with `EnterWorktree` `path:`, not `name:`. +## Agent-Specific Worktree Tools + +Provider-specific mechanics stay separate from the general creation procedure above: + +- **Claude Code:** its `EnterWorktree` tool acts only on an explicit instruction from the user or + project instructions. Given a `name`, it creates the worktree under `.claude/worktrees/` and + bases it on the GitHub default branch. Both differ from the fleet path and base. Create the + worktree with `git worktree add`, then attach with `EnterWorktree` `path:`, not `name:`. +- **Codex:** no provider-specific creation override applies. Use the general `git worktree add` + procedure above. Its host-specific writable-root setting lives in `docs/host-setup.md` "Agent + Worktree Access". +- **opencode:** no provider-specific creation override applies. Use the general + `git worktree add` procedure above. ## Preparing Git Hooks diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 0765fd52..3d7a3ada 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -221,20 +221,30 @@ CI runs the full lint set, but run the linters locally before pushing to catch i - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. -The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc or headless (agent) runs. +The Docker invocations below run the same tools and configs as the VS Code tasks. Their headless form separates the image pull and minimizes repository exposure for an agent executor. -**Restricted executors keep tool state in a task-specific writable temporary directory.** Set each tool's own cache variable, such as `UV_CACHE_DIR` and `RUFF_CACHE_DIR`, instead of changing `HOME` or an agent configuration directory. A sandbox denial is not a lint result. Preserve the denial, then rerun the required command through the executor's scoped approval mechanism. Network approval covers a package or image fetch. Host approval covers access to the Docker socket. Use a narrow reusable command prefix when the executor supports one, and report the approved rerun as the evidence. +**Restricted executors keep tool state in a task-specific writable temporary directory.** Set each tool's own cache variable, such as `UV_CACHE_DIR` and `RUFF_CACHE_DIR`, instead of changing `HOME` or an agent configuration directory. A sandbox denial is not a lint result. Preserve the denial, then rerun the required command through the executor's scoped approval mechanism. Network approval covers any required fetch, including an image or package download. Host approval covers access to the Docker socket. Repository-exposure approval covers letting third-party image code read the checkout, even through a read-only mount. Persist approval only when the executor constrains the read-only mount, disabled networking, and resolved digest together. Never allow an unconstrained `docker run` prefix. PSScriptAnalyzer's separate module-install phase gets network approval without any repository mount. Report the approved rerun as the evidence. + +Agent-specific authorization stays separate from the executor-neutral contract above: + +- **Codex:** execution rules match exact argument prefixes, so they cannot safely cover changing worktree paths and digests. Smart Approvals can therefore request repository-exposure approval per task. The no-prompt alternative combines `sandbox_mode = "danger-full-access"` with `approval_policy = "never"`. Use that pair only when an external sandbox contains the Codex process. It removes protection from every command rather than only lint. + +Pull each image before analysis, then resolve and run the pulled repository digest. A digest prevents the tag from changing between the pull and the run. It does not make third-party code trusted. Keep the resolved digest in a tool-specific variable when several commands run in one shell. - **editorconfig-checker** (line endings + charset across the tree): ```sh - docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest + docker pull mstruebing/editorconfig-checker:latest + EDITORCONFIG_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' mstruebing/editorconfig-checker:latest)" + docker run --rm --network=none --mount type=bind,src="$PWD",dst=/check,readonly --workdir /check "$EDITORCONFIG_IMAGE" ``` - **actionlint** (GitHub Actions workflow YAML, run after any `.github/workflows/` edit, since workflow-only changes are not smoke-built): ```sh - docker run --rm --pull=always -v "$PWD":/repo --workdir /repo rhysd/actionlint:latest -color + docker pull rhysd/actionlint:latest + ACTIONLINT_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' rhysd/actionlint:latest)" + docker run --rm --network=none --mount type=bind,src="$PWD",dst=/repo,readonly --workdir /repo "$ACTIONLINT_IMAGE" -color ``` The `rhysd/actionlint` image bundles `shellcheck`, so it also validates `run:` shell blocks. The direct-binary/curl-installer path is often sandbox-blocked, so use Docker. @@ -242,22 +252,32 @@ The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc - **markdownlint-cli2** (Markdown, mirroring the davidanson VS Code extension via the shared [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), so the CLI and IDE agree): ```sh - docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir davidanson/markdownlint-cli2:latest "**/*.md" + docker pull davidanson/markdownlint-cli2:latest + MARKDOWNLINT_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' davidanson/markdownlint-cli2:latest)" + docker run --rm --network=none --mount type=bind,src="$PWD",dst=/workdir,readonly --workdir /workdir "$MARKDOWNLINT_IMAGE" "**/*.md" ``` - **cspell** (spelling in user-facing docs, with the word list and exclusions in [`cspell.json`](./cspell.json)): ```sh - docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md + docker pull ghcr.io/streetsidesoftware/cspell:latest + CSPELL_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/streetsidesoftware/cspell:latest)" + docker run --rm --network=none --mount type=bind,src="$PWD",dst=/workdir,readonly --workdir /workdir "$CSPELL_IMAGE" --no-progress README.md HISTORY.md ``` - **PSScriptAnalyzer** (PowerShell, the peer of the shellcheck step, **only applies to a repo that carries `.ps1` files**, which carries `PSScriptAnalyzerSettings.psd1` alongside them with the excluded rules and their reasons): ```sh - docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ + docker pull mcr.microsoft.com/powershell:latest + POWERSHELL_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' mcr.microsoft.com/powershell:latest)" + docker volume create projecttemplate-psscriptanalyzer-1.23.0 + docker run --rm --mount source=projecttemplate-psscriptanalyzer-1.23.0,target=/root/.local/share/powershell/Modules "$POWERSHELL_IMAGE" \ + pwsh -NoProfile -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope CurrentUser' + docker run --rm --network=none -e PS_SCRIPTS="$(git ls-files '*.ps1')" \ + --mount type=bind,src="$PWD",dst=/mnt,readonly \ + --mount source=projecttemplate-psscriptanalyzer-1.23.0,target=/root/.local/share/powershell/Modules,readonly \ + --workdir /mnt "$POWERSHELL_IMAGE" \ pwsh -NoProfile -Command ' - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers Import-Module PSScriptAnalyzer $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 } @@ -269,7 +289,7 @@ The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc ' ``` - The module version is pinned beside the image, because the image alone does not fix it and a floating install makes a local run a different check from CI. 1.23.0 rather than the newest, since 1.24.0 needs a newer `System.Management.Automation` than the image carries and fails to import after installing cleanly. The file list comes from `git ls-files` for the same reason the shellcheck step uses it, and the count is printed because a run that read no files reports the same clean as one that read them all. + The module-install container has network access and no repository mount. The analyzer container uses read-only mounts and has networking disabled. The module version is pinned because the image alone does not fix it. A floating install makes a local run differ from CI. Use 1.23.0 because 1.24.0 needs a newer `System.Management.Automation` than the image carries. The newer module fails to import after installing cleanly. The file list comes from `git ls-files`, for the same reason the shellcheck step uses it. The count distinguishes a complete clean run from one that read no files. **The list splits on whitespace rather than on a newline, and the regex is double-quoted.** A shell joins the file list with newlines and PowerShell joins it with spaces, so a newline-only split hands the analyzer one path holding every file, which it reports as one file it cannot find followed by a clean run over nothing. The double quotes are what let the whole invocation stay inside the single-quoted `-Command` a shell passes, since PowerShell escapes with a backtick and leaves the backslash alone. Run verbatim it reports `Checked 5 file(s)` from either shell. diff --git a/OPERATIONS.md b/OPERATIONS.md index f04da91b..a0b093d2 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -32,12 +32,20 @@ python3 scripts/prose_lint.py . --check charset --check semicolon --check dash - python3 scripts/prose_lint.py . --check charset-unknown --summary for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f"; done python3 spec/validate.py -docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest -scripts=(); while IFS= read -r f; do scripts+=("$f"); done < <(git ls-files '*.sh'); docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" -docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest pwsh -NoProfile -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers; Import-Module PSScriptAnalyzer; $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ }; if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 }; $found = @(); foreach ($f in $files) { $found += Invoke-ScriptAnalyzer -Path $f -Settings ./PSScriptAnalyzerSettings.psd1 }; Write-Host "Checked $($files.Count) file(s)"; if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 }; Write-Host "no findings"' +docker pull mstruebing/editorconfig-checker:latest +EDITORCONFIG_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' mstruebing/editorconfig-checker:latest)" +docker run --rm --network=none --mount type=bind,src="$PWD",dst=/check,readonly --workdir /check "$EDITORCONFIG_IMAGE" +docker pull koalaman/shellcheck:stable +SHELLCHECK_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' koalaman/shellcheck:stable)" +scripts=(); while IFS= read -r f; do scripts+=("$f"); done < <(git ls-files '*.sh'); docker run --rm --network=none --mount type=bind,src="$PWD",dst=/mnt,readonly --workdir /mnt "$SHELLCHECK_IMAGE" "${scripts[@]}" +docker pull mcr.microsoft.com/powershell:latest +POWERSHELL_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' mcr.microsoft.com/powershell:latest)" +docker volume create projecttemplate-psscriptanalyzer-1.23.0 +docker run --rm --mount source=projecttemplate-psscriptanalyzer-1.23.0,target=/root/.local/share/powershell/Modules "$POWERSHELL_IMAGE" pwsh -NoProfile -Command 'Set-PSRepository PSGallery -InstallationPolicy Trusted; Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope CurrentUser' +docker run --rm --network=none -e PS_SCRIPTS="$(git ls-files '*.ps1')" --mount type=bind,src="$PWD",dst=/mnt,readonly --mount source=projecttemplate-psscriptanalyzer-1.23.0,target=/root/.local/share/powershell/Modules,readonly --workdir /mnt "$POWERSHELL_IMAGE" pwsh -NoProfile -Command 'Import-Module PSScriptAnalyzer; $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ }; if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 }; $found = @(); foreach ($f in $files) { $found += Invoke-ScriptAnalyzer -Path $f -Settings ./PSScriptAnalyzerSettings.psd1 }; Write-Host "Checked $($files.Count) file(s)"; if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 }; Write-Host "no findings"' ``` -The cache directory is unique to this verification run and remains outside the checkout. The operating system can reap it with other temporary data. A restricted executor may deny the first `uvx` network request or the first Docker socket access. Record that denial as an execution boundary, then rerun the required command with scoped approval. Only the rerun's tool output is a lint or test verdict. +The cache directory is unique to this verification run and remains outside the checkout. The operating system can reap it with other temporary data. A restricted executor may deny the first `uvx` network request, Docker socket access, or third-party image access to the repository. Record that denial as an execution boundary, then rerun the required command with scoped approval. Persist repository-exposure approval only when the executor constrains the read-only mount, disabled networking, and digest together. Only the rerun's tool output is a lint or test verdict. Provider-specific host configuration lives in [`docs/host-setup.md`](./docs/host-setup.md) "Agent Worktree Access". The `test_install.py` line behaves differently here than in CI, stated so its failure reads as the verdict it is. Its report cases install from this checkout and assert the machine then reads as current. An install from a checkout carrying uncommitted changes records a dirty stamp that reads as stale. So on a working tree mid-change those cases fail by design where CI's clean checkout passes. The remedy is to run them again once the change is committed, not to read the failure as a regression. @@ -111,8 +119,12 @@ A local gate reproduces a CI failure exactly, because CI runs the same commands The Docker linters pull `:latest` deliberately, so a local run matches whatever CI resolved: ```sh -docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir davidanson/markdownlint-cli2:latest "**/*.md" -docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md +docker pull davidanson/markdownlint-cli2:latest +MARKDOWNLINT_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' davidanson/markdownlint-cli2:latest)" +docker run --rm --network=none --mount type=bind,src="$PWD",dst=/workdir,readonly --workdir /workdir "$MARKDOWNLINT_IMAGE" "**/*.md" +docker pull ghcr.io/streetsidesoftware/cspell:latest +CSPELL_IMAGE="$(docker image inspect --format '{{index .RepoDigests 0}}' ghcr.io/streetsidesoftware/cspell:latest)" +docker run --rm --network=none --mount type=bind,src="$PWD",dst=/workdir,readonly --workdir /workdir "$CSPELL_IMAGE" --no-progress README.md HISTORY.md ``` Both commands are the canonical invocations from [GOVERNANCE.md](./GOVERNANCE.md). markdownlint reads every Markdown file, while cspell reads `README.md` and `HISTORY.md` only. That narrower spelling scope is deliberate, since gating every Markdown file would mean padding `cspell.json` with technical terms without end, and broad live spell-check is the editor extension's job. Widening it here produces noise that no gate acts on. diff --git a/README.md b/README.md index 377575d1..329053d2 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,13 @@ Four things are deployed from here, and they land in different places. The host ### Deploy the Host Guardrails -The guardrails are the one component that is installed rather than read, and they are **host state rather than repository content**, because they have to cover ad-hoc sessions in no project at all. They deny a mis-targeted GitHub write under your identity, and a git operation that would only land by bypassing a branch rule. +The guardrails are **host state rather than repository content**, because they have to cover ad-hoc sessions in no project at all. Each provider's implementation stays separate: + +- **Claude Code:** the installable safety kit denies a mis-targeted GitHub write under your identity. It also denies a git operation that would bypass a branch rule. +- **Codex:** no equivalent host hook ships yet. The carried repository rules and Codex's own sandbox and execution policies remain active. [Issue #781][issue-781] tracks the missing hook. +- **opencode:** no equivalent host hook ships yet. The carried repository rules and opencode's own permission model remain active. [Issue #781][issue-781] tracks the missing hook. + +#### Claude Code ```shell host-setup/agent-safety/install.sh # Linux, WSL, macOS @@ -171,7 +177,7 @@ host-setup/agent-safety/install.sh # Linux, WSL, macOS .\host-setup\agent-safety\install.ps1 # Windows, and the .\ prefix is required ``` -Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/README.md`][agent-safety], and the surrounding host prerequisites (git identity, SSH signing, `gh`, `docker`, `uv`) are in [`docs/host-setup.md`][host-setup]. +Restart Claude Code sessions on the machine afterward so the hook and the `CLAUDE.md` blocks load. The installer is idempotent, so re-running it is also how a machine picks up an upstream change to the guard. What it installs, how to verify it, and what it deliberately does not catch are in [`host-setup/agent-safety/README.md`][agent-safety]. The surrounding host prerequisites are in [`docs/host-setup.md`][host-setup]. ### Install the Fleet Skills @@ -359,6 +365,7 @@ Licensed under the [MIT License][license]\ [host-setup]: ./docs/host-setup.md [host-setup-dir]: ./host-setup/ [host-setup-granting-a-write-the-guard-denies]: ./docs/host-setup.md#granting-a-write-the-guard-denies +[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 [license]: ./LICENSE [matrix]: ./reports/conformance-matrix.md [project-types]: ./spec/project-types.json diff --git a/cspell.json b/cspell.json index 719199bf..de95aedf 100644 --- a/cspell.json +++ b/cspell.json @@ -87,6 +87,7 @@ "noqa", "nuget", "onCreateCommand", + "opencode", "opencontainers", "Optix", "othercommand", diff --git a/docs/host-setup.md b/docs/host-setup.md index a84ffc06..68de4451 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -167,9 +167,13 @@ gh auth login --hostname github.com --git-protocol ssh Choose the SSH key generated above when prompted. -## Agent Write-Safety Kit +## Agent Write-Safety -Required on any host where an agent runs with the `gh` credentials logged in, and its own README calls it the first thing to deploy on a new system. Install it from this repo, since the installer is idempotent and safe to re-run to update: +Host-level write safety is required where an agent runs with the maintainer's `gh` credentials. Each provider's implementation stays in its own subsection. + +### Claude Code Write Safety + +The Claude Code safety kit is the first agent-specific control to deploy on a new system. Install it from this repo. The installer is idempotent and safe to re-run: ```shell host-setup/agent-safety/install.sh # Linux, WSL, macOS @@ -183,7 +187,7 @@ Both wrap one `install.py`, so every platform runs the same tested path. Restart This is a **host** control, not a repo one. The carried `GOVERNANCE.md` rules reach fleet repos only, while the hook and the `CLAUDE.md` block cover every session on the machine, including ad-hoc work in no project at all, which is where the incident behind the kit happened. -### Granting a Write the Guard Denies +#### Granting a Write the Guard Denies The guard denies a `gh` write whose explicit target sits under an owner other than the checkout's `origin` owner, and the denial names `GH_WRITE_GUARD_ALLOW` as the way past it. That grant is the maintainer's to make, and making it is a deliberate act taken outside the session rather than something an agent does for itself once blocked. @@ -219,6 +223,49 @@ Run it bare, with no `VAR=value` prefix of its own, which would report a value t Withdraw a grant by deleting the `env` entry and restarting. Nothing expires it, so a grant left in place stays live for every later session in that checkout, which is the reason to remove it once the work that needed it is done. +### Codex Write Safety + +No equivalent host write hook ships yet for Codex. Keep Codex's sandbox and execution policies enabled. The carried repository rules remain the behavioral layer in a fleet checkout. [Issue #781][issue-781] tracks the missing hook. + +### opencode Write Safety + +No equivalent host write hook ships yet for opencode. Keep opencode's own permission model enabled. The carried repository rules remain the behavioral layer in a fleet checkout. [Issue #781][issue-781] tracks the missing hook. + +## Agent Worktree Access + +Fleet tasks create registered worktrees beside the primary checkout, under the host's standard worktree directory. That path is host-specific, and permission configuration is agent-specific. Configure each agent on every host rather than carrying one repository setting that assumes a username or home directory. + +The shared requirement is access to the same worktree parent. Each agent's configuration stays in its own subsection below. + +### Codex Worktree Access + +Codex reads its user configuration from `~/.codex/config.toml`, including sessions started by the VS Code extension. Keep the workspace sandbox and add the absolute worktree parent as an extra writable root: + +```toml +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = ["/absolute/path/to/repos/worktrees"] +``` + +Use the real host path. A Linux, macOS, or WSL host using the fleet layout normally resolves it to `/home//repos/worktrees` or `/Users//repos/worktrees`. Native Windows uses a form such as `C:/Users//repos/worktrees`. Use forward slashes in the TOML value. Reload the VS Code window or restart the Codex session after changing the file, since a running session keeps the permissions it started with. + +Validate the file before relying on it: + +```shell +codex --strict-config --version +``` + +This setting grants filesystem writes only under the worktree parent. It does not grant Docker socket access or authorize a third-party container to read a checkout. Codex treats those as separate approval boundaries, per [the official configuration reference][codex-config]. Do not replace this setting with `danger-full-access`, and do not add an unconstrained `docker run` execution rule. + +### Claude Code Worktree Access + +Claude Code does not read Codex's `config.toml`. Keep its permission mode in Claude Code's user-level configuration. Its sessions must be allowed to create and edit the same host-specific worktree parent. + +### opencode Worktree Access + +opencode does not read Codex's `config.toml`. Keep its permission mode in opencode's user-level configuration. Its sessions must be allowed to create and edit the same host-specific worktree parent. + ## Fleet Skills Install The fleet's agent skills are hand-authored in the hub at `.agents/skills/` and installed per user by [`scripts/skills_install.py`][skills-install]: an overlay copy into `~/.agents/skills/` for Codex and opencode, and a user-scope Claude Code plugin install where the `claude` CLI is present. Every run stamps the hub commit it installed from into `~/.agents/skills-install-stamp.json`, and `--report` reads that stamp against the checkout and exits non-zero where the machine is behind it. @@ -311,6 +358,7 @@ A host that fails any row is not ready for the procedure that row names, and the [host-setup-windows]: ../host-setup/windows/ [host-tools]: ../spec/host-tools.json [issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 +[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 [operations]: ../OPERATIONS.md [scripts-dir]: ../scripts/ [skills-install]: ../scripts/skills_install.py @@ -321,6 +369,7 @@ A host that fails any row is not ready for the procedure that row names, and the +[codex-config]: https://developers.openai.com/codex/config-reference/ [cli-install-link]: https://github.com/cli/cli/blob/trunk/docs/install_linux.md [cli-link]: https://cli.github.com/ [docker-install-link]: https://docs.docker.com/engine/install/ diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index 52719a5f..a7da831b 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -1,6 +1,6 @@ -# Agent Write-Safety Kit +# Claude Code Write-Safety Kit -Per-machine, user-account-scoped guards against an agent making a mis-targeted GitHub **write** under the maintainer's identity, or a **git operation that bypasses a branch rule or overrides a required check**, meaning a push, force-push, or delete that an active branch rule forbids, or an override flag (`--admin` past the server-side merge gate, `--no-verify` past the local git hooks). Deploy it as the **first thing on any new system** where Claude Code runs with the `gh` credentials logged in (Linux, WSL, macOS, Windows). +These per-machine, user-account-scoped Claude Code guards cover two action classes. The first is a mis-targeted GitHub **write** under the maintainer's identity. The second is a **git operation that bypasses a branch rule or required check**. That class includes a forbidden push, force-push, or delete. It also includes an override flag such as `--admin` or `--no-verify`. Deploy the kit first on any system where Claude Code uses the maintainer's `gh` credentials. Linux, WSL, macOS, and Windows are supported. This kit does not configure Codex or opencode. Their host-specific controls live in [`docs/host-setup.md` "Agent Write-Safety"][host-setup-write-safety], and [issue #781][issue-781] tracks their missing hooks. ## What It Installs @@ -100,4 +100,6 @@ Every other key in the file is left as it stands, `permissions.allow` included, [governance]: ../../GOVERNANCE.md [host-setup-grant]: ../../docs/host-setup.md#granting-a-write-the-guard-denies +[host-setup-write-safety]: ../../docs/host-setup.md#agent-write-safety [issue-365]: https://github.com/ptr727/ProjectTemplate/issues/365 +[issue-781]: https://github.com/ptr727/ProjectTemplate/issues/781 diff --git a/spec/scope-model.md b/spec/scope-model.md index 5504ea41..7d33a6d0 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -6,7 +6,7 @@ How every governance rule is scoped, so the carried docs are granular single-sco A rule has a physical home, and a repo rule has a reach as well. -- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/`, loading in every session regardless of repo and covering ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ. The write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. +- **Axis A, home.** A rule lives on the **host** or in the **repo**. A host rule is per-machine and lives in an agent's own user configuration. It loads regardless of repo and covers ad-hoc work outside any project. A repo rule travels with a repo and can assume repo context. A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ. The write-safety rules are the worked example, living in provider-specific host configuration and the carried `AGENTS.md` at once. `host-setup/` owns each supported provider's host form. - **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo: the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. ## Selectors