From 873aaea75071210146d6f853472192e0daa93c1e Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 29 Aug 2026 20:44:26 -0600 Subject: [PATCH 01/30] fix(runtime): preserve nested tool protocols --- go/cmd/coding-ethos-run/dispatch.go | 68 +++++++++- go/cmd/coding-ethos-run/main_test.go | 117 +++++++++++++++++- go/internal/policygitcli/main.go | 11 ++ .../policygitcli/main_internal_test.go | 42 +++++++ 4 files changed, 235 insertions(+), 3 deletions(-) diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index c5a8c8e0..f7f86974 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -33,6 +33,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/realgit" "blackcat.ca/coding-ethos/go/internal/shellparse" "blackcat.ca/coding-ethos/go/internal/shellquote" + "blackcat.ca/coding-ethos/go/toolcatalog" ) const ( @@ -854,7 +855,10 @@ func runPolicyGitHandler(paths runtimePaths, rest []string) error { runtimeExecTool( paths, "coding-ethos-git", - append([]string{"--bundle", bundlePath, "--real-git", realGitPath}, rest...)...) + append( + []string{"--bundle", bundlePath, "--real-git", realGitPath, "--"}, + rest..., + )...) return nil } @@ -1003,16 +1007,78 @@ func runAgentHooksCommand(paths runtimePaths, rest []string) { } func runPolicyTool(paths runtimePaths, rest []string) error { + return runPolicyToolForParent(paths, rest, parentExecutablePath()) +} + +func runPolicyToolForParent( + paths runtimePaths, + rest []string, + parentExecutable string, +) error { if len(rest) == 0 { return apperror.StaticError("policy-tool requires a tool name") } requirePolicyBundle(paths) + + if actionlintShellcheckDependency(parentExecutable, rest[0], rest[1:]) { + tool, found := toolcatalog.HookOwnedTool("shellcheck") + if !found { + return apperror.StaticError("managed shellcheck tool is not registered") + } + + shellcheck := tool.ManagedExecutablePath(paths.EthosRoot) + requireRuntimeBinary(shellcheck, "managed shellcheck dependency") + paths.executor().execPath(shellcheck, rest[1:]...) + + return nil + } + runtimeExecLint(paths, policyToolLintArgs(paths, rest[0], rest[1:])...) return nil } +func parentExecutablePath() string { + if runtime.GOOS != linuxGOOS { + return "" + } + + path, err := os.Readlink("/proc/" + strconv.Itoa(os.Getppid()) + "/exe") + if err != nil { + return "" + } + + return path +} + +func actionlintShellcheckDependency( + parentExecutable string, + tool string, + args []string, +) bool { + if filepath.Base(parentExecutable) != "actionlint" || + tool != "shellcheck" || + len(args) == 0 || + args[len(args)-1] != "-" { + return false + } + + for index, arg := range args { + if (arg == "-f" || arg == "--format") && + index+1 < len(args) && + args[index+1] == "json" { + return true + } + + if arg == "-f=json" || arg == "--format=json" { + return true + } + } + + return false +} + func runMCP(paths runtimePaths, rest []string) { bundlePath := hookPolicyBundlePath(paths) requireRuntimeFile(bundlePath, "compiled policy bundle") diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index f2fbe93e..dcd5ddde 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -27,6 +27,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/sharedlock" "blackcat.ca/coding-ethos/go/internal/shellquote" "blackcat.ca/coding-ethos/go/internal/testlock" + "blackcat.ca/coding-ethos/go/toolcatalog" ) func TestRunnerArgsInferGitHookFromExecutableName(t *testing.T) { @@ -2400,7 +2401,7 @@ func TestPolicyGitIgnoresSpoofedAgentShellSandboxEnv(t *testing.T) { if !strings.Contains( got, "exec:coding-ethos-git --bundle "+hookPolicyBundlePath(paths)+ - " --real-git "+paths.RealGit+" status", + " --real-git "+paths.RealGit+" -- status", ) { t.Fatalf("policy-git did not execute managed git: %#v", calls) } @@ -2431,12 +2432,124 @@ func TestPolicyGitIgnoresArbitraryEnvRealGitExecutable(t *testing.T) { if !strings.Contains( got, "exec:coding-ethos-git --bundle "+hookPolicyBundlePath(paths)+ - " --real-git "+paths.RealGit+" status", + " --real-git "+paths.RealGit+" -- status", ) { t.Fatalf("policy-git did not execute managed git: %#v", calls) } } +func TestPolicyToolExecutesActionlintShellcheckDependencyRaw(t *testing.T) { + paths := runtimeTestPaths(t) + var calls []string + paths.Executor = stubRuntimeOps{calls: &calls} + + tool, found := toolcatalog.HookOwnedTool("shellcheck") + if !found { + t.Fatal("managed shellcheck tool is not registered") + } + + shellcheck := tool.ManagedExecutablePath(paths.EthosRoot) + if err := os.MkdirAll(filepath.Dir(shellcheck), 0o755); err != nil { + t.Fatalf("create managed shellcheck fixture directory: %v", err) + } + writeExecutableFixture(t, shellcheck, "#!/usr/bin/env sh\nexit 0\n") + + args := []string{ + "shellcheck", + "--norc", + "-f", "json", + "-x", + "--shell", "bash", + "-", + } + err := runPolicyToolForParent(paths, args, "/managed/bin/actionlint") + if err != nil { + t.Fatalf("run actionlint shellcheck dependency: %v", err) + } + + want := "execpath:" + shellcheck + " " + strings.Join(args[1:], " ") + if !slices.Contains(calls, want) { + t.Fatalf("managed shellcheck was not executed raw; calls = %#v", calls) + } +} + +func TestPolicyToolCapturesShellcheckOutsideActionlint(t *testing.T) { + paths := runtimeTestPaths(t) + var calls []string + paths.Executor = stubRuntimeOps{calls: &calls} + + err := runPolicyToolForParent( + paths, + []string{"shellcheck", "-f", "json", "-"}, + "/usr/bin/bash", + ) + if err != nil { + t.Fatalf("run direct shellcheck: %v", err) + } + + joined := strings.Join(calls, "\n") + if !strings.Contains(joined, "exec-lint:") || strings.Contains(joined, "execpath:") { + t.Fatalf("direct shellcheck bypassed managed capture: %#v", calls) + } +} + +func TestActionlintShellcheckDependencyRequiresJSONStdinContract(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parent string + tool string + args []string + want bool + }{ + { + name: "actionlint json stdin", + parent: "/managed/bin/actionlint", + tool: "shellcheck", + args: []string{"--norc", "-f", "json", "-"}, + want: true, + }, + { + name: "long json format", + parent: "/managed/bin/actionlint", + tool: "shellcheck", + args: []string{"--format=json", "-"}, + want: true, + }, + { + name: "wrong parent", + parent: "/usr/bin/bash", + tool: "shellcheck", + args: []string{"-f", "json", "-"}, + }, + { + name: "not stdin", + parent: "/managed/bin/actionlint", + tool: "shellcheck", + args: []string{"-f", "json", "script.sh"}, + }, + { + name: "not json", + parent: "/managed/bin/actionlint", + tool: "shellcheck", + args: []string{"-f", "gcc", "-"}, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := actionlintShellcheckDependency(test.parent, test.tool, test.args) + if got != test.want { + t.Fatalf("actionlintShellcheckDependency() = %v, want %v", got, test.want) + } + }) + } +} + func TestAgentShellNativeGitBindRequiresReadOnlyMountInfo(t *testing.T) { t.Parallel() diff --git a/go/internal/policygitcli/main.go b/go/internal/policygitcli/main.go index 6d25c46e..dd7e3a77 100644 --- a/go/internal/policygitcli/main.go +++ b/go/internal/policygitcli/main.go @@ -187,6 +187,8 @@ func gitOptions( } } + argv = withoutInitialRedundantChangeDir(argv) + stdin, err := stdinForGitArgv(argv) if err != nil { return gitwrap.Options{}, err @@ -200,6 +202,15 @@ func gitOptions( }, nil } +func withoutInitialRedundantChangeDir(argv []string) []string { + index := 0 + for index+1 < len(argv) && argv[index] == "-C" && argv[index+1] == "." { + index += 2 + } + + return append([]string(nil), argv[index:]...) +} + func stdinForGitArgv(argv []string) ([]byte, error) { if !gitCommitReadsMessageFromStdin(argv) { return nil, nil diff --git a/go/internal/policygitcli/main_internal_test.go b/go/internal/policygitcli/main_internal_test.go index 2ca2606c..201d8ba6 100644 --- a/go/internal/policygitcli/main_internal_test.go +++ b/go/internal/policygitcli/main_internal_test.go @@ -84,6 +84,48 @@ func TestGitOptionsForNonStdinCommand(t *testing.T) { } } +func TestGitOptionsDropsOnlyInitialRedundantChangeDir(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + argv []string + want string + }{ + { + name: "redundant current directory", + argv: []string{"-C", ".", "diff", "--staged"}, + want: "diff --staged", + }, + { + name: "real directory change remains policy visible", + argv: []string{"-C", "../other", "diff", "--staged"}, + want: "-C ../other diff --staged", + }, + { + name: "operation change detection flag is untouched", + argv: []string{"diff", "-C", "."}, + want: "diff -C .", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + options, err := gitOptions(test.argv, t.TempDir(), false) + if err != nil { + t.Fatalf("gitOptions: %v", err) + } + + if got := strings.Join(options.Argv, " "); got != test.want { + t.Fatalf("argv = %q, want %q", got, test.want) + } + }) + } +} + func TestReadBundleAndMaybePrintJSON(t *testing.T) { t.Parallel() From 8ff94cf5a21a74536e9f100e5e65a7a4840294d5 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 29 Aug 2026 23:07:46 -0600 Subject: [PATCH 02/30] fix(parent): isolate lane runtime surfaces --- Makefile | 8 +-- README.md | 10 +++ go/cmd/coding-ethos-run/main_test.go | 67 +++++++++++++++++++ go/cmd/coding-ethos-run/parent_workflow.go | 20 ++++-- ...act_agent_skill_sync_is_not_user_facing.py | 5 +- 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 6b104b36..75634d51 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,7 @@ fi endef define install_git_hooks -$(call print_info,hooks: $(1)); "$(GO_TOOLS_BIN_DIR)/coding-ethos-toolchain" install-git-hooks --hooks-dir "$(1)" --runner "$(GO_HOOK)" +$(call print_info,hooks: $(1)); "$(GO_TOOLS_BIN_DIR)/coding-ethos-toolchain" install-git-hooks --hooks-dir "$(1)" --runner "$(2)" endef HOOK_CONSUMER_ROOT := $(shell $(resolve_hook_consumer_root)) @@ -648,11 +648,11 @@ go-hook-runner-install: ensure-go ## Build the bundled Go hook runner into the c @cd "$(GO_TOOLS_DIR)" && "$(GO)" build $(GO_BUILD_FLAGS) -o "$(LOCAL_BIN_DIR)/coding-ethos-hook-runner" ./cmd/coding-ethos-hook-runner @$(call print_info,installed: $(LOCAL_BIN_DIR)/coding-ethos-hook-runner) -_sync-git-hooks: ensure-go go-tools-install +_sync-git-hooks: ensure-go go-tools-install _sync-parent-hook-runtime @$(call print_step,Syncing Git hook entrypoints) - @$(call install_git_hooks,$(LOCAL_HOOKS_DIR)) + @$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(GO_HOOK)) @if [ "$(HOOKS_DIR)" != "$(LOCAL_HOOKS_DIR)" ]; then \ - $(call install_git_hooks,$(HOOKS_DIR)); \ + $(call install_git_hooks,$(HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run); \ fi _sync-parent-hook-runtime: ensure-go go-tools-install policy-bundle-install diff --git a/README.md b/README.md index 59d169a3..af60c99c 100644 --- a/README.md +++ b/README.md @@ -931,6 +931,16 @@ install/check emit only status plus artifact-step rows, while parent lint emits the normal coding-ethos TOON lint report. See `TO_MY_PARENT.md` for the parent artifact contract. +When `parent-install` or `parent-lint` receives an external `--state-root`, it +leaves the consumer checkout's tracked `.gitignore` unchanged. Other generated +parent artifacts remain normal consumer surfaces; repo-local state retains the +runtime-ignore repair. + +Git hooks installed for a parent repository route through its stable common +Git runtime at `.git/coding-ethos-hooks/bin/coding-ethos-run`. They never point +at a worktree-local build path, so one worktree cannot strand every sibling's +hooks when its own checkout is retired or hidden by a lane sandbox. + Parent repos can opt into profile defaults in `repo_config.yaml`: ```yaml diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index dcd5ddde..6f692be8 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -665,6 +665,46 @@ func TestRuntimePolicyBundleUsesPrivateStateRoot(t *testing.T) { } } +func TestParentUsesExternalStateRoot(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + options parentWorkflowOptions + want bool + }{ + {name: "implicit repo-local", options: parentWorkflowOptions{Repo: "/repo"}}, + { + name: "explicit repo-local", + options: parentWorkflowOptions{Repo: "/repo", StateRoot: "/repo"}, + }, + { + name: "clean-equivalent repo-local", + options: parentWorkflowOptions{Repo: "/repo/.", StateRoot: "/repo"}, + }, + { + name: "external", + options: parentWorkflowOptions{Repo: "/repo", StateRoot: "/private/state"}, + want: true, + }, + { + name: "nested external", + options: parentWorkflowOptions{Repo: "/repo", StateRoot: "/repo/private-state"}, + want: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + if got := parentUsesExternalStateRoot(test.options); got != test.want { + t.Fatalf("parentUsesExternalStateRoot() = %t, want %t", got, test.want) + } + }) + } +} + func TestParentStepStatusFailsOnAnyFailedStep(t *testing.T) { t.Parallel() @@ -3326,6 +3366,33 @@ func TestMakefileRoutesLintTargetsThroughManagedGroups(t *testing.T) { } } +func TestMakefileRoutesParentGitHooksThroughStableCommonRuntime(t *testing.T) { + t.Parallel() + + payload, err := os.ReadFile(filepath.Join("..", "..", "..", "Makefile")) + if err != nil { + t.Fatalf("read Makefile: %v", err) + } + + makefile := string(payload) + for _, want := range []string{ + `$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(GO_HOOK))`, + `$(call install_git_hooks,$(HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run)`, + `_sync-git-hooks: ensure-go go-tools-install _sync-parent-hook-runtime`, + } { + if !strings.Contains(makefile, want) { + t.Fatalf("Makefile missing stable Git hook route %q", want) + } + } + + if strings.Contains( + makefile, + `$(call install_git_hooks,$(HOOKS_DIR),$(GO_HOOK))`, + ) { + t.Fatal("Makefile routes parent Git hooks through a worktree-local runner") + } +} + func fakeCIGit(t *testing.T, diffOutput string) string { t.Helper() diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index 9cdbcdd6..e813f995 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -270,14 +270,16 @@ func syncParentArtifacts( steps = append(steps, runParentStep("agent_hooks", func() error { return agenthooks.SyncSettings(options.Repo, parentAgentHookCommand(paths)) })) - steps = append(steps, runParentStep("repo_ignores", func() error { - _, err := repoignore.RepairGitignore(options.Repo) - if err != nil { - return fmt.Errorf("repair repo ignores: %w", err) - } + if !parentUsesExternalStateRoot(options) { + steps = append(steps, runParentStep("repo_ignores", func() error { + _, err := repoignore.RepairGitignore(options.Repo) + if err != nil { + return fmt.Errorf("repair repo ignores: %w", err) + } - return nil - })) + return nil + })) + } steps = append(steps, runParentStep("code_intel", func() error { return refreshParentCodeIntel(options.Repo) })) @@ -285,6 +287,10 @@ func syncParentArtifacts( return steps } +func parentUsesExternalStateRoot(options parentWorkflowOptions) bool { + return options.StateRoot != "" && !sameCleanPath(options.StateRoot, options.Repo) +} + func checkParentArtifacts( paths runtimePaths, options parentWorkflowOptions, diff --git a/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py b/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py index 22eaf866..9b04d6e1 100644 --- a/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py +++ b/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py @@ -25,7 +25,10 @@ def test_agent_skill_sync_is_not_user_facing() -> None: assert "\t_sync-parent-hook-runtime \\" not in phony_block assert "_sync-agent-skills: ensure-go\n" in makefile assert "_sync-consumer-agent-skills: ensure-go\n" in makefile - assert "_sync-git-hooks: ensure-go go-tools-install\n" in makefile + assert ( + "_sync-git-hooks: ensure-go go-tools-install _sync-parent-hook-runtime\n" + in makefile + ) assert ( "_sync-parent-hook-runtime: ensure-go go-tools-install policy-bundle-install\n" in makefile From ff2de2270374190918c45963603443e8643d3805 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sat, 29 Aug 2026 23:13:27 -0600 Subject: [PATCH 03/30] fix(go): satisfy whitespace lint --- go/cmd/coding-ethos-run/parent_workflow.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index e813f995..aba39f4d 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -270,6 +270,7 @@ func syncParentArtifacts( steps = append(steps, runParentStep("agent_hooks", func() error { return agenthooks.SyncSettings(options.Repo, parentAgentHookCommand(paths)) })) + if !parentUsesExternalStateRoot(options) { steps = append(steps, runParentStep("repo_ignores", func() error { _, err := repoignore.RepairGitignore(options.Repo) @@ -280,6 +281,7 @@ func syncParentArtifacts( return nil })) } + steps = append(steps, runParentStep("code_intel", func() error { return refreshParentCodeIntel(options.Repo) })) From ed817de2d01370dfd671ce7551f01154fabd9da3 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sun, 30 Aug 2026 17:37:43 -0600 Subject: [PATCH 04/30] fix(runtime): harden gates and code-intel replay --- README.md | 9 +- TODO.md | 6 + TO_MY_PARENT.md | 9 +- docs/CODE_INTEL.md | 13 +- docs/HOOK_RUNTIME_BOOTSTRAP.md | 130 ++-- go/cmd/coding-ethos-run/dispatch.go | 2 +- go/cmd/coding-ethos-run/main_test.go | 106 ++- go/cmd/coding-ethos-run/parent_workflow.go | 223 +++++- go/internal/codeintel/duckdb_store.go | 47 +- go/internal/codeintel/schema.go | 4 + .../codeintel/search_identity_migration.go | 198 ++++++ .../search_identity_migration_test.go | 248 +++++++ go/internal/codeintel/store.go | 94 ++- go/internal/codeintel/store_migration.go | 3 +- .../codeintel/store_migration_manifest.go | 3 +- go/internal/codeintel/store_migration_rows.go | 65 +- .../codeintel/store_migration_schema.go | 14 +- go/internal/codeintel/store_migration_test.go | 71 +- go/internal/codeintel/write.go | 146 +++- go/internal/hookrunnercli/export.go | 8 + go/internal/hookrunnercli/external_tool.go | 53 ++ .../external_tool_internal_test.go | 26 + go/internal/hookrunnercli/git_hook.go | 43 +- .../hookrunnercli/git_hook_internal_test.go | 19 + go/internal/hooks/gate_exit_status.go | 638 ++++++++++++++++++ go/internal/hooks/gate_exit_status_test.go | 64 ++ go/internal/hooks/git_wrapper_enforcement.go | 10 +- go/internal/hooks/lint_tool_capture.go | 7 +- go/internal/hooks/normalizer_internal_test.go | 20 + go/internal/hooks/proxy_output.go | 2 +- go/internal/hooks/runner.go | 4 + .../hooks/semantic_policy_injection.go | 2 +- go/internal/policy/bundle.go | 51 +- go/internal/policy/hook_route_policies.go | 38 ++ go/internal/policygitcli/main.go | 200 +++++- .../policygitcli/main_internal_test.go | 60 ++ pre-commit/PRE-COMMIT.md | 16 +- pre-commit/hooks/HOOKS.md | 14 +- 38 files changed, 2451 insertions(+), 215 deletions(-) create mode 100644 go/internal/codeintel/search_identity_migration.go create mode 100644 go/internal/codeintel/search_identity_migration_test.go create mode 100644 go/internal/hooks/gate_exit_status.go create mode 100644 go/internal/hooks/gate_exit_status_test.go create mode 100644 go/internal/hooks/normalizer_internal_test.go diff --git a/README.md b/README.md index af60c99c..d48180a0 100644 --- a/README.md +++ b/README.md @@ -931,6 +931,11 @@ install/check emit only status plus artifact-step rows, while parent lint emits the normal coding-ethos TOON lint report. See `TO_MY_PARENT.md` for the parent artifact contract. +`parent-install` rebuilds the checkout-authoritative Go tools and atomically +projects byte-identical executables into the parent repository's stable common +Git runtime. `parent-check` hashes both sides and fails if that projection is +missing, non-executable, symlinked back to a retiring checkout, or stale. + When `parent-install` or `parent-lint` receives an external `--state-root`, it leaves the consumer checkout's tracked `.gitignore` unchanged. Other generated parent artifacts remain normal consumer surfaces; repo-local state retains the @@ -939,7 +944,9 @@ runtime-ignore repair. Git hooks installed for a parent repository route through its stable common Git runtime at `.git/coding-ethos-hooks/bin/coding-ethos-run`. They never point at a worktree-local build path, so one worktree cannot strand every sibling's -hooks when its own checkout is retired or hidden by a lane sandbox. +hooks when its own checkout is retired or hidden by a lane sandbox. Running the +supported parent workflow refreshes and verifies that shared executable +projection as part of the same install/check contract. Parent repos can opt into profile defaults in `repo_config.yaml`: diff --git a/TODO.md b/TODO.md index 39f01ded..d34f5bc5 100644 --- a/TODO.md +++ b/TODO.md @@ -250,6 +250,12 @@ Goal: make the checked-out `coding-ethos` repository the single build and runtime source of truth. Consumer repository hooks should only discover, repair, and dispatch. +> Historical plan: the completed phases below describe the earlier +> checkout-local runtime. The current worktree-safe contract keeps the selected +> checkout as source/build authority and installs a byte-verified executable +> projection in the Git-common `.git/coding-ethos-hooks` runtime. See +> `docs/HOOK_RUNTIME_BOOTSTRAP.md` for the superseding architecture. + ### Phase 1 - Runtime Layout - [x] Replace the consumer `.git/coding-ethos-hooks` runtime cache with diff --git a/TO_MY_PARENT.md b/TO_MY_PARENT.md index cb4c2946..6a9ec9d4 100644 --- a/TO_MY_PARENT.md +++ b/TO_MY_PARENT.md @@ -45,9 +45,12 @@ profiles: Explicit `repo_config.yaml` settings override these profile defaults. -`parent-install` syncs generated parent artifacts. `parent-check` verifies those -artifacts without rewriting them. `parent-lint` syncs the parent artifacts, then -runs the full parent lint scope through the compiled policy bundle. +`parent-install` syncs generated parent artifacts and atomically refreshes the +compiled executables in the parent repository's common +`.git/coding-ethos-hooks/bin/` runtime. `parent-check` verifies those artifacts +without rewriting them, including byte identity and executable independence +from any retiring worktree. `parent-lint` syncs the parent artifacts, then runs +the full parent lint scope through the compiled policy bundle. ## Output Contract diff --git a/docs/CODE_INTEL.md b/docs/CODE_INTEL.md index 54b65175..92a40b32 100644 --- a/docs/CODE_INTEL.md +++ b/docs/CODE_INTEL.md @@ -17,7 +17,8 @@ Use explicit storage layers with separate logical responsibilities: content-addressed base manifest is shared through the Git common directory; each worktree owns its delta manifest, tombstones, and current-generation receipt. No shared writable DuckDB is used for these source facts. -- **DuckDB remains the v1-compatible analytical and telemetry store.** It owns +- **DuckDB is a schema-v2 analytical and telemetry store with v1 upgrade + compatibility.** It owns traces, policy decisions, remediations, outcomes, derived AST graph edges, file metadata, and full-text search. Existing v1 stores are retained during the v2 migration and can be rebuilt explicitly as derived state. @@ -156,6 +157,16 @@ remediation text. duckdb-vss is active for derived vector rows, but DuckDB facts remain the auditable source of truth. +Writable stores use schema v2. Search identities are enforced by unique +constraints on `code_intel_fts.fts_id` and on `(term, fts_id)` in +`code_intel_search_terms`. Upgrading or replaying a v1 store collapses exact +duplicate rows, fails closed when one identity carries conflicting content, +and reports row and duplicate counts through store statistics. Migration +manifests use kind v2, record `deduplicated_rows`, and exclude the schema +metadata row from migrated-data accounting. File, chunk, and graph-edge +replays use conflict-aware updates so rebuilding search state does not discard +retained AST or foreign-key evidence. + Graph facts expose provenance classes wherever repo maps, graph reports, and MCP graph surfaces show those facts. `EXTRACTED` marks parser/static-analysis facts, while `GIT_DERIVED`, `POLICY_DERIVED`, `TRACE_DERIVED`, and diff --git a/docs/HOOK_RUNTIME_BOOTSTRAP.md b/docs/HOOK_RUNTIME_BOOTSTRAP.md index cde36936..282c8a40 100644 --- a/docs/HOOK_RUNTIME_BOOTSTRAP.md +++ b/docs/HOOK_RUNTIME_BOOTSTRAP.md @@ -5,50 +5,55 @@ ## Decision -The consumer repository hook shim must not own policy behavior. +The consumer repository hook shim must not own policy behavior, and it must not +point at a worktree that can be retired while sibling worktrees still use the +same Git common directory. Its job is limited to: -- discover the consumer repository root -- locate the checked-out `coding-ethos` bundle for that repository -- verify that required built artifacts exist inside that checkout -- repair missing artifacts by running supported `make` targets in the - `coding-ethos` checkout -- dispatch to the built hook binary +- identify the hook kind +- dispatch to the compiled runner installed in the repository's stable common + Git runtime Policy evaluation, policy freshness, generated prompt packs, runtime command selection, managed capture, diagnostics parsing, and hook behavior belong to -the `coding-ethos` checkout. The shim is only a bootstrap and dispatch layer. +the compiled Coding Ethos runtime. The checked-out Coding Ethos authority is +the source and build authority; `.git/coding-ethos-hooks` is an installed, +byte-verified projection for durable sibling-worktree execution. ## Rationale -The `coding-ethos` checkout is already required for normal operation. Keeping a -second runtime cache under the consumer repository `.git` directory creates two -possible sources of truth: +Git hook entrypoints live in the Git common directory and are shared by every +worktree. Pointing those entrypoints at one authority checkout creates a hidden +lifetime dependency: retiring or sandbox-hiding that checkout strands every +sibling hook even though the common Git directory remains healthy. -- the checked-out `coding-ethos` source tree -- the installed `.git/coding-ethos-hooks` runtime cache +The common runtime is therefore a projection, not a second source of truth: -That split is fragile. Worktrees, submodules, generated policy files, touched -configuration, and branch switches can cause the hook shim, `make build`, and -runtime validation to resolve different roots. When that happens, lifecycle -hooks can fail even though the correct repair command has been run elsewhere. +- `coding-ethos/bin` is built from the selected authority checkout. +- `parent-install` atomically copies every compiled Go command into the common + runtime. +- `parent-check` requires regular executable files with byte-identical SHA-256 + content and rejects symlinks back to an authority checkout. +- `make build` installs the complete policy, pre-commit, toolchain, shim, and + executable projection. -The simpler invariant is: +The invariant is: ```text -make in the coding-ethos checkout builds the hook runtime. -hooks run the hook runtime from the coding-ethos checkout. +the selected checkout builds and verifies authority artifacts. +hooks execute the stable common projection of those artifacts. ``` -If the `coding-ethos` checkout is present and can build, the hook path should -self-heal missing runtime artifacts. If the checkout is missing or cannot build, -the error should name the exact checkout path and command required to fix it. +If the projection is missing or stale, the error must name the exact supported +`parent-install` or `make build` command. Lifecycle hooks must not silently +select another worktree as authority. ## Target Runtime Layout -Runtime artifacts live under the `coding-ethos` checkout and are ignored by -Git: +Authority build artifacts live under the selected `coding-ethos` checkout and +are ignored by Git. The installed hook runtime lives under the consumer +repository's Git common directory: ```text coding-ethos/ @@ -68,10 +73,18 @@ coding-ethos/ go-bin/{golangci-lint,shfmt} github-bin/{actionlint,dotenv-linter,hadolint,shellcheck} prefix/bin/ + +/coding-ethos-hooks/ + bin/{coding-ethos-*,cerun,git,lint} + policy/{policy-bundle.json,policy-metadata.json} + pre-commit/ + build/{policy,toolchain}/ + {coding_ethos.yml,repo_ethos.yml,config.yaml} ``` -Consumer repository hooks should not install or validate policy bundles under -the consumer `.git` directory. +The common projection is runtime state and must never be committed. It is +updated only through the supported install/build workflow and validated against +the selected authority, rather than edited directly. ## Managed Toolchain @@ -133,26 +146,26 @@ path. ## Hook Entrypoint Contract -The installed consumer repository hook entrypoint should be a small executable -script generated from the compiled `bin/coding-ethos-run` binary. The script -passes the hook kind and hook name explicitly, for example -`coding-ethos-run git-hook pre-commit "$@"`, so installed Git hooks do not rely -on `argv[0]` inference. +The installed consumer repository hook entrypoint is a small executable script. +It passes the hook kind and hook name explicitly, for example +`/coding-ethos-hooks/bin/coding-ethos-run git-hook pre-commit +"$@"`, so installed Git hooks do not rely on `argv[0]` inference or a +worktree-local path. -The compiled runner owns the bootstrap contract: +The supported install/check workflow owns the bootstrap contract: -1. Resolve the consumer repository root from Git. -2. Locate `coding-ethos`, preferably at `$consumer_root/coding-ethos`. -3. Fail with a clear submodule checkout instruction if it is missing. -4. Check for required artifacts in the `coding-ethos` checkout. -5. If artifacts are missing, run: +1. Resolve the consumer repository and its absolute Git common directory. +2. Build Go tools from the explicitly selected Coding Ethos authority. +3. Atomically install the compiled executables into the common runtime. +4. Install the remaining policy, toolchain, and hook artifacts through + `make build` when the full runtime is being refreshed. +5. Verify executable type, mode, and byte identity with: ```bash - make -C "$coding_ethos_root" build + coding-ethos/bin/coding-ethos-run parent-check --repo "$consumer_root" ``` -6. Re-check the required artifacts. -7. Exec the built hook binary from the `coding-ethos` checkout. +6. Install Git entrypoints that dispatch only to the common runner. The hook entrypoint contract must not: @@ -161,19 +174,22 @@ The hook entrypoint contract must not: - select policy files - inspect policy source configuration - rewrite generated protected files by hand -- maintain a second runtime cache in the consumer `.git` directory +- point to a worktree-local authority path +- accept a symlinked executable projection back to an authority checkout - write response caches or other transient runtime state into `.git` ## Repair Rules -Bootstrap repair should run only when required artifacts are missing or invalid -enough that they cannot be executed. It should not run because a timestamp looks -old. +Bootstrap repair is explicit. `parent-install` refreshes generated parent +artifacts and compiled common-runtime executables; `make build` refreshes the +complete projection. It should not run because a timestamp looks old. -Examples that should trigger repair: +Examples that require repair: - missing hook binary - non-executable hook binary +- symlinked common-runtime executable +- executable whose SHA-256 differs from the authority build - missing compiled policy bundle - unreadable compiled policy bundle - missing managed toolchain manifest @@ -185,7 +201,7 @@ Examples that should not block lifecycle hooks: - source config has a newer mtime than the compiled bundle - generated files were touched by checkout tools -- another worktree has a different runtime cache +- another worktree has different ignored build products Strict freshness validation belongs in explicit maintainer/CI commands such as `make validate`, `make cutover-verify`, and CI. Freshness is based on the @@ -200,11 +216,13 @@ Bootstrap needs a few guardrails: - Use an interprocess lock, preferably `flock`, so concurrent hooks do not run multiple builds over the same output directory. - Print the exact failed command and preserve build output when repair fails. -- Keep build outputs under ignored `bin/` and `build/` directories. -- Keep transient repo-local runtime caches under ignored `.coding-ethos/cache/` - paths, not under `.git`. -- Keep installed hook entrypoints as stable generated scripts and move - versioned behavior into the `coding-ethos` checkout. +- Keep authority build outputs under ignored `bin/` and `build/` directories. +- Keep response, trace, and other transient repo-local caches under ignored + `.coding-ethos/` paths, not under the Git common runtime. +- Install common-runtime executables with temporary-file sync plus atomic + rename, and verify them by content rather than mtime. +- Keep installed hook entrypoints stable and move versioned behavior into the + compiled common projection. ## Hook Execution Model @@ -266,6 +284,8 @@ complete coverage. ## Migration Direction -Runtime artifacts are built and executed from the checked-out `coding-ethos` -repository. New hook behavior must use that single source of truth instead of -adding cache-local compatibility paths. +Runtime artifacts are built from an explicitly selected `coding-ethos` +authority and executed from the stable common Git projection. New hook behavior +must preserve that one-way authority-to-projection relationship: no hook may +select an arbitrary sibling checkout, and no installed executable may link back +to a checkout that can be retired. diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index f7f86974..fc423463 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -856,7 +856,7 @@ func runPolicyGitHandler(paths runtimePaths, rest []string) error { paths, "coding-ethos-git", append( - []string{"--bundle", bundlePath, "--real-git", realGitPath, "--"}, + []string{"--bundle", bundlePath, "--real-git", realGitPath}, rest..., )...) diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 6f692be8..aa4246e2 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -780,6 +780,79 @@ func TestParentGoToolsCheckPassesWhenBinariesAreCurrent(t *testing.T) { } } +func TestParentHookRuntimeSyncConvergesAndCheckDetectsDrift(t *testing.T) { + t.Parallel() + + paths := runtimeTestPaths(t) + paths.ToolsSource = parentGoToolsSourceFixture(t, paths.EthosRoot) + touchParentGoTools(t, paths, time.Now()) + options := parentWorkflowOptions{Repo: paths.Root} + + err := syncParentHookRuntimeExecutables(paths, options) + if err != nil { + t.Fatalf("syncParentHookRuntimeExecutables: %v", err) + } + + err = checkParentHookRuntimeExecutables(paths, options) + if err != nil { + t.Fatalf("checkParentHookRuntimeExecutables: %v", err) + } + + installed := filepath.Join( + parentHookRuntimeBinDir(paths, options), + "coding-ethos-run", + ) + writeExecutableFixture(t, installed, "#!/usr/bin/env sh\necho stale\n") + + err = checkParentHookRuntimeExecutables(paths, options) + if !errors.Is(err, errParentArtifactDrift) || + !strings.Contains(err.Error(), "coding-ethos-run(content)") || + !strings.Contains(err.Error(), "parent-install") { + t.Fatalf("check drift error = %v", err) + } + + err = syncParentHookRuntimeExecutables(paths, options) + if err != nil { + t.Fatalf("repair parent hook runtime: %v", err) + } + + err = checkParentHookRuntimeExecutables(paths, options) + if err != nil { + t.Fatalf("check repaired parent hook runtime: %v", err) + } +} + +func TestParentHookRuntimeCheckRejectsCheckoutSymlink(t *testing.T) { + t.Parallel() + + paths := runtimeTestPaths(t) + paths.ToolsSource = parentGoToolsSourceFixture(t, paths.EthosRoot) + touchParentGoTools(t, paths, time.Now()) + options := parentWorkflowOptions{Repo: paths.Root} + runtimeBin := parentHookRuntimeBinDir(paths, options) + + err := os.MkdirAll(runtimeBin, 0o755) + if err != nil { + t.Fatalf("create hook runtime: %v", err) + } + + for _, tool := range parentGoToolFixtureCommands() { + destination := filepath.Join(runtimeBin, tool) + source := filepath.Join(paths.BinDir, tool) + + err = os.Symlink(source, destination) + if err != nil { + t.Fatalf("symlink %s: %v", tool, err) + } + } + + err = checkParentHookRuntimeExecutables(paths, options) + if !errors.Is(err, errParentArtifactDrift) || + !strings.Contains(err.Error(), "coding-ethos-run(not_regular)") { + t.Fatalf("check symlink error = %v", err) + } +} + func TestSyncParentPolicyBundleUsesParentRepoConfig(t *testing.T) { t.Parallel() @@ -2441,12 +2514,41 @@ func TestPolicyGitIgnoresSpoofedAgentShellSandboxEnv(t *testing.T) { if !strings.Contains( got, "exec:coding-ethos-git --bundle "+hookPolicyBundlePath(paths)+ - " --real-git "+paths.RealGit+" -- status", + " --real-git "+paths.RealGit+" status", ) { t.Fatalf("policy-git did not execute managed git: %#v", calls) } } +func TestPolicyGitForwardsWrapperFlagsBeforeGitArgv(t *testing.T) { + paths := runtimeTestPaths(t) + var calls []string + paths.Executor = stubRuntimeOps{calls: &calls} + writePolicyBundleForTest(t, hookPolicyBundlePath(paths)) + + err := run(paths, []string{ + "policy-git", + "--admin-approved", + "--check-only", + "commit", + "-m", + "verified", + }) + if err != nil { + t.Fatalf("run policy-git with wrapper flags: %v", err) + } + + got := strings.Join(calls, "\n") + if !strings.Contains( + got, + "exec:coding-ethos-git --bundle "+hookPolicyBundlePath(paths)+ + " --real-git "+paths.RealGit+ + " --admin-approved --check-only commit -m verified", + ) { + t.Fatalf("policy-git did not preserve wrapper flags: %#v", calls) + } +} + func TestPolicyGitIgnoresArbitraryEnvRealGitExecutable(t *testing.T) { paths := runtimeTestPaths(t) var calls []string @@ -2472,7 +2574,7 @@ func TestPolicyGitIgnoresArbitraryEnvRealGitExecutable(t *testing.T) { if !strings.Contains( got, "exec:coding-ethos-git --bundle "+hookPolicyBundlePath(paths)+ - " --real-git "+paths.RealGit+" -- status", + " --real-git "+paths.RealGit+" status", ) { t.Fatalf("policy-git did not execute managed git: %#v", calls) } diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index aba39f4d..2b87b48d 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -5,6 +5,7 @@ package main import ( "context" + "crypto/sha256" "errors" "flag" "fmt" @@ -41,11 +42,14 @@ const ( ) var ( - errParentArtifactDrift = errors.New("parent artifact drift") - errParentGoToolsStale = errors.New("parent Go tools are stale") - errParentPathIsDirectory = errors.New("path is a directory, want file") - errParentPathIsNotDirectory = errors.New("path is not a directory, want directory") - errParentRootNotAbsolute = errors.New("parent workflow root must be absolute") + errParentArtifactDrift = errors.New("parent artifact drift") + errParentGoToolsStale = errors.New("parent Go tools are stale") + errParentPathIsDirectory = errors.New("path is a directory, want file") + errParentPathIsNotDirectory = errors.New("path is not a directory, want directory") + errParentRootNotAbsolute = errors.New("parent workflow root must be absolute") + errParentSourceNotExecutable = errors.New( + "source executable is not a regular executable", + ) ) type parentWorkflowOptions struct { @@ -240,6 +244,9 @@ func syncParentArtifacts( steps = append(steps, runParentStep("go_tools", func() error { return rebuildParentGoTools(paths) })) + steps = append(steps, runParentStep("hook_runtime", func() error { + return syncParentHookRuntimeExecutables(paths, options) + })) steps = append(steps, runParentStep("policy_bundle", func() error { return syncParentPolicyBundle(paths, options) })) @@ -301,6 +308,9 @@ func checkParentArtifacts( steps = append(steps, runParentStep("go_tools", func() error { return checkParentGoTools(paths, options) })) + steps = append(steps, runParentStep("hook_runtime", func() error { + return checkParentHookRuntimeExecutables(paths, options) + })) steps = append(steps, runParentStep("policy_bundle", func() error { return checkParentPolicyBundle(paths, options) })) @@ -434,6 +444,209 @@ func parentGitCommonDir(paths runtimePaths, repo string) string { return filepath.Join(repo, ".git") } +func parentHookRuntimeBinDir(paths runtimePaths, options parentWorkflowOptions) string { + return filepath.Join( + parentGitCommonDir(paths, options.Repo), + "coding-ethos-hooks", + "bin", + ) +} + +func syncParentHookRuntimeExecutables( + paths runtimePaths, + options parentWorkflowOptions, +) error { + tools, err := parentGoToolCommands(paths) + if err != nil { + return err + } + + runtimeBin := parentHookRuntimeBinDir(paths, options) + + err = os.MkdirAll(runtimeBin, parentExecutableDirMode) + if err != nil { + return fmt.Errorf("create parent hook runtime bin dir: %w", err) + } + + for _, tool := range tools { + source := filepath.Join(paths.BinDir, tool) + destination := filepath.Join(runtimeBin, tool) + + err = installParentHookRuntimeExecutable(source, destination) + if err != nil { + return fmt.Errorf("install parent hook runtime %s: %w", tool, err) + } + } + + return nil +} + +func installParentHookRuntimeExecutable(source, destination string) error { + info, err := os.Lstat(source) + if err != nil { + return fmt.Errorf("stat source executable %s: %w", source, err) + } + + if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return fmt.Errorf("%w: %s", errParentSourceNotExecutable, source) + } + + input, err := os.Open(source) + if err != nil { + return fmt.Errorf("open source executable %s: %w", source, err) + } + defer input.Close() + + temporary, err := os.CreateTemp( + filepath.Dir(destination), + "."+filepath.Base(destination)+"-*.tmp", + ) + if err != nil { + return fmt.Errorf("create temporary hook runtime executable: %w", err) + } + + temporaryPath := temporary.Name() + + defer func() { + _ = temporary.Close() + _ = os.Remove(temporaryPath) + }() + + err = temporary.Chmod(info.Mode().Perm()) + if err != nil { + return fmt.Errorf("set temporary hook runtime executable mode: %w", err) + } + + _, err = io.Copy(temporary, input) + if err != nil { + return fmt.Errorf("copy hook runtime executable: %w", err) + } + + err = temporary.Sync() + if err != nil { + return fmt.Errorf("sync temporary hook runtime executable: %w", err) + } + + err = temporary.Close() + if err != nil { + return fmt.Errorf("close temporary hook runtime executable: %w", err) + } + + err = os.Rename(temporaryPath, destination) + if err != nil { + return fmt.Errorf("activate hook runtime executable %s: %w", destination, err) + } + + return nil +} + +func checkParentHookRuntimeExecutables( + paths runtimePaths, + options parentWorkflowOptions, +) error { + tools, err := parentGoToolCommands(paths) + if err != nil { + return err + } + + runtimeBin := parentHookRuntimeBinDir(paths, options) + mismatched := []string{} + + for _, tool := range tools { + source := filepath.Join(paths.BinDir, tool) + destination := filepath.Join(runtimeBin, tool) + + status, err := compareParentHookRuntimeExecutable(source, destination) + if err != nil { + return fmt.Errorf("check parent hook runtime %s: %w", tool, err) + } + + if status != "" { + mismatched = append(mismatched, tool+"("+status+")") + } + } + + if len(mismatched) == 0 { + return nil + } + + return fmt.Errorf( + "%w: hook_runtime out of sync in %s checkout; run: %s; drift: %s", + errParentArtifactDrift, + parentCheckoutLocation(paths, options), + parentInstallCommand(options), + strings.Join(mismatched, " "), + ) +} + +func compareParentHookRuntimeExecutable(source, destination string) (string, error) { + sourceInfo, err := os.Lstat(source) + if err != nil { + return "", fmt.Errorf("stat source executable %s: %w", source, err) + } + + if !sourceInfo.Mode().IsRegular() || sourceInfo.Mode()&0o111 == 0 { + return "", fmt.Errorf("%w: %s", errParentSourceNotExecutable, source) + } + + destinationInfo, err := os.Lstat(destination) + if errors.Is(err, os.ErrNotExist) { + return "missing", nil + } + + if err != nil { + return "", fmt.Errorf("stat installed executable %s: %w", destination, err) + } + + if !destinationInfo.Mode().IsRegular() { + return "not_regular", nil + } + + if destinationInfo.Mode()&0o111 == 0 { + return "not_executable", nil + } + + if sourceInfo.Size() != destinationInfo.Size() { + return "content", nil + } + + sourceHash, err := parentFileSHA256(source) + if err != nil { + return "", err + } + + destinationHash, err := parentFileSHA256(destination) + if err != nil { + return "", err + } + + if sourceHash != destinationHash { + return "content", nil + } + + return "", nil +} + +func parentFileSHA256(path string) ([sha256.Size]byte, error) { + file, err := os.Open(path) + if err != nil { + return [sha256.Size]byte{}, fmt.Errorf("open executable %s: %w", path, err) + } + defer file.Close() + + hash := sha256.New() + + _, err = io.Copy(hash, file) + if err != nil { + return [sha256.Size]byte{}, fmt.Errorf("hash executable %s: %w", path, err) + } + + var digest [sha256.Size]byte + copy(digest[:], hash.Sum(nil)) + + return digest, nil +} + func compileParentPolicyBundle( paths runtimePaths, options parentWorkflowOptions, diff --git a/go/internal/codeintel/duckdb_store.go b/go/internal/codeintel/duckdb_store.go index 7900e034..4d5d00f1 100644 --- a/go/internal/codeintel/duckdb_store.go +++ b/go/internal/codeintel/duckdb_store.go @@ -20,9 +20,10 @@ import ( ) const ( - duckDBStoreMode = 0o700 - duckDBLockFileMode = 0o600 - duckDBStaleLockAge = 30 * time.Minute + duckDBStoreMode = 0o700 + duckDBLockFileMode = 0o600 + duckDBStaleLockAge = 30 * time.Minute + duckDBExtendedStatsQueryCount = 15 ) // DuckDBStore is the code-intel analytical query store. @@ -552,14 +553,7 @@ func (store *DuckDBStore) migrate(ctx context.Context) error { return err } - for _, statement := range duckDBSchemaStatements() { - _, err := store.database.ExecContext(ctx, statement) - if err != nil { - return fmt.Errorf("migrate DuckDB code-intel store: %w", err) - } - } - - return nil + return migrateStore(ctx, store.database) } func (store *DuckDBStore) ping(ctx context.Context) error { @@ -657,7 +651,8 @@ func duckDBCoreStatsQueries(stats *Stats) []statCountQuery { } func duckDBExtendedStatsQueries(stats *Stats) []statCountQuery { - return []statCountQuery{ + queries := make([]statCountQuery, 0, duckDBExtendedStatsQueryCount) + queries = append(queries, []statCountQuery{ { name: "code_chunks", query: "SELECT COUNT(*) FROM code_chunks", @@ -713,10 +708,38 @@ func duckDBExtendedStatsQueries(stats *Stats) []statCountQuery { query: "SELECT COUNT(*) FROM embedding_records", target: &stats.EmbeddingRecords, }, + }...) + + return append(queries, duckDBSearchStatsQueries(stats)...) +} + +func duckDBSearchStatsQueries(stats *Stats) []statCountQuery { + return []statCountQuery{ { name: "code_intel_fts", query: "SELECT COUNT(*) FROM code_intel_fts", target: &stats.FtsRows, }, + { + name: "duplicate code_intel_fts identities", + query: `SELECT + (SELECT COUNT(*) FROM code_intel_fts) - + (SELECT COUNT(*) FROM (SELECT DISTINCT fts_id FROM code_intel_fts) AS identities)`, + target: &stats.FtsDuplicateRows, + }, + { + name: "code_intel_search_terms", + query: "SELECT COUNT(*) FROM code_intel_search_terms", + target: &stats.SearchTermRows, + }, + { + name: "duplicate code_intel_search_terms identities", + query: `SELECT + (SELECT COUNT(*) FROM code_intel_search_terms) - + (SELECT COUNT(*) FROM ( + SELECT DISTINCT term, fts_id FROM code_intel_search_terms + ) AS identities)`, + target: &stats.SearchTermDuplicateRows, + }, } } diff --git a/go/internal/codeintel/schema.go b/go/internal/codeintel/schema.go index 453d6000..831908ec 100644 --- a/go/internal/codeintel/schema.go +++ b/go/internal/codeintel/schema.go @@ -672,6 +672,10 @@ func indexSchemaStatements() []string { ON code_chunks(normalized_hash)`, `CREATE INDEX IF NOT EXISTS idx_lsh_bands_lookup ON lsh_bands(band_hash, band_index)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_code_intel_fts_id_unique + ON code_intel_fts(fts_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_code_intel_search_terms_unique + ON code_intel_search_terms(term, fts_id)`, `CREATE INDEX IF NOT EXISTS idx_code_intel_search_terms_term ON code_intel_search_terms(term, fts_id)`, `CREATE INDEX IF NOT EXISTS idx_code_intel_search_terms_fts_id diff --git a/go/internal/codeintel/search_identity_migration.go b/go/internal/codeintel/search_identity_migration.go new file mode 100644 index 00000000..9b1f8d9f --- /dev/null +++ b/go/internal/codeintel/search_identity_migration.go @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package codeintel + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +var ( + errFTSIdentityConflict = errors.New( + "conflicting code intelligence FTS rows share identity", + ) + errFTSIdentityMissing = errors.New( + "code intelligence FTS row has no durable identity", + ) +) + +type ftsIdentityContent struct { + kind sql.NullString + recordID sql.NullString + traceID sql.NullString + policyID sql.NullString + skillID sql.NullString + path sql.NullString + message sql.NullString + searchText sql.NullString +} + +// deduplicateSearchIdentity upgrades the v1 logical keys before unique +// indexes are created. Equal crash/reindex replays collapse to one row; +// conflicting rows for the same identity fail closed because choosing either +// would silently change search evidence. +func deduplicateSearchIdentity(ctx context.Context, database *sql.DB) error { + transaction, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin search identity migration: %w", err) + } + defer rollbackUnlessCommitted(transaction) + + ftsDuplicates, err := inspectFTSIdentityDuplicates(ctx, transaction) + if err != nil { + return err + } + + termDuplicates, err := inspectSearchTermDuplicates(ctx, transaction) + if err != nil { + return err + } + + if ftsDuplicates > 0 { + for _, statement := range []string{ + `CREATE OR REPLACE TEMP TABLE code_intel_fts_deduplicated AS + SELECT DISTINCT * FROM code_intel_fts`, + "DELETE FROM code_intel_fts", + "INSERT INTO code_intel_fts SELECT * FROM code_intel_fts_deduplicated", + "DROP TABLE code_intel_fts_deduplicated", + } { + _, execErr := transaction.ExecContext(ctx, statement) + if execErr != nil { + return fmt.Errorf("deduplicate code intelligence FTS rows: %w", execErr) + } + } + } + + if termDuplicates > 0 { + for _, statement := range []string{ + `CREATE OR REPLACE TEMP TABLE code_intel_terms_deduplicated AS + SELECT DISTINCT term, fts_id FROM code_intel_search_terms`, + "DELETE FROM code_intel_search_terms", + `INSERT INTO code_intel_search_terms(term, fts_id) + SELECT term, fts_id FROM code_intel_terms_deduplicated`, + "DROP TABLE code_intel_terms_deduplicated", + } { + _, execErr := transaction.ExecContext(ctx, statement) + if execErr != nil { + return fmt.Errorf("deduplicate code intelligence search terms: %w", execErr) + } + } + } + + commitErr := transaction.Commit() + if commitErr != nil { + return fmt.Errorf("commit search identity migration: %w", commitErr) + } + + return nil +} + +func inspectFTSIdentityDuplicates( + ctx context.Context, + transaction *sql.Tx, +) (int, error) { + rows, err := transaction.QueryContext( + ctx, + `SELECT fts_id, kind, record_id, trace_id, policy_id, skill_id, + path, message, search_text + FROM code_intel_fts`, + ) + if err != nil { + return 0, fmt.Errorf("inspect code intelligence FTS identities: %w", err) + } + defer rows.Close() + + seen := map[string]ftsIdentityContent{} + duplicates := 0 + + for rows.Next() { + var ( + identity sql.NullString + content ftsIdentityContent + ) + + scanErr := rows.Scan( + &identity, + &content.kind, + &content.recordID, + &content.traceID, + &content.policyID, + &content.skillID, + &content.path, + &content.message, + &content.searchText, + ) + if scanErr != nil { + return 0, fmt.Errorf("scan code intelligence FTS identity: %w", scanErr) + } + + if !identity.Valid || identity.String == "" { + return 0, errFTSIdentityMissing + } + + if previous, ok := seen[identity.String]; ok { + if previous != content { + return 0, fmt.Errorf("%w: %q", errFTSIdentityConflict, identity.String) + } + + duplicates++ + + continue + } + + seen[identity.String] = content + } + + rowsErr := rows.Err() + if rowsErr != nil { + return 0, fmt.Errorf("iterate code intelligence FTS identities: %w", rowsErr) + } + + return duplicates, nil +} + +func inspectSearchTermDuplicates( + ctx context.Context, + transaction *sql.Tx, +) (int, error) { + rows, err := transaction.QueryContext( + ctx, + "SELECT term, fts_id FROM code_intel_search_terms", + ) + if err != nil { + return 0, fmt.Errorf("inspect code intelligence search term identities: %w", err) + } + defer rows.Close() + + seen := map[[2]string]struct{}{} + duplicates := 0 + + for rows.Next() { + var term, identity string + + scanErr := rows.Scan(&term, &identity) + if scanErr != nil { + return 0, fmt.Errorf("scan code intelligence search term identity: %w", scanErr) + } + + key := [2]string{term, identity} + + if _, ok := seen[key]; ok { + duplicates++ + + continue + } + + seen[key] = struct{}{} + } + + rowsErr := rows.Err() + if rowsErr != nil { + return 0, fmt.Errorf("iterate code intelligence search term identities: %w", rowsErr) + } + + return duplicates, nil +} diff --git a/go/internal/codeintel/search_identity_migration_test.go b/go/internal/codeintel/search_identity_migration_test.go new file mode 100644 index 00000000..0fd52d1a --- /dev/null +++ b/go/internal/codeintel/search_identity_migration_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package codeintel + +import ( + "context" + "database/sql" + "strings" + "testing" +) + +func TestOpenMigratesExactDuplicateSearchIdentities(t *testing.T) { + ctx := context.Background() + path, database := openLegacySearchIdentityFixture(t, ctx) + + _, err := database.ExecContext( + ctx, + `INSERT INTO code_intel_fts( + fts_id, kind, record_id, trace_id, path, message, search_text + ) VALUES + ('legacy:one', 'finding', 'one', 'trace', 'one.go', 'same', 'same text'), + ('legacy:one', 'finding', 'one', 'trace', 'one.go', 'same', 'same text'); + INSERT INTO code_intel_search_terms(term, fts_id) VALUES + ('same', 'legacy:one'), + ('same', 'legacy:one')`, + ) + if err != nil { + t.Fatalf("insert legacy duplicate identities: %v", err) + } + if err = database.Close(); err != nil { + t.Fatalf("close legacy identity fixture: %v", err) + } + + store, err := Open(ctx, path) + if err != nil { + t.Fatalf("upgrade legacy duplicate identities: %v", err) + } + defer store.Close() + + stats, err := store.Stats(ctx) + if err != nil { + t.Fatalf("read upgraded identity stats: %v", err) + } + if stats.SchemaVersion != schemaVersion || stats.FtsRows != 1 || + stats.SearchTermRows != 1 || stats.FtsDuplicateRows != 0 || + stats.SearchTermDuplicateRows != 0 { + t.Fatalf("unexpected upgraded identity stats: %#v", stats) + } + + _, err = store.Database().ExecContext( + ctx, + `INSERT INTO code_intel_fts(fts_id, kind, record_id, search_text) + VALUES ('legacy:one', 'finding', 'two', 'other')`, + ) + if err == nil { + t.Fatal("upgraded FTS identity accepted a duplicate") + } + _, err = store.Database().ExecContext( + ctx, + `INSERT INTO code_intel_search_terms(term, fts_id) + VALUES ('same', 'legacy:one')`, + ) + if err == nil { + t.Fatal("upgraded search-term identity accepted a duplicate") + } +} + +func TestOpenRejectsConflictingDuplicateSearchIdentity(t *testing.T) { + ctx := context.Background() + path, database := openLegacySearchIdentityFixture(t, ctx) + + _, err := database.ExecContext( + ctx, + `INSERT INTO code_intel_fts( + fts_id, kind, record_id, trace_id, path, message, search_text + ) VALUES + ('legacy:conflict', 'finding', 'one', 'trace', 'one.go', 'first', 'same text'), + ('legacy:conflict', 'finding', 'one', 'trace', 'one.go', 'second', 'same text')`, + ) + if err != nil { + t.Fatalf("insert conflicting legacy identities: %v", err) + } + if err = database.Close(); err != nil { + t.Fatalf("close conflicting identity fixture: %v", err) + } + + _, err = Open(ctx, path) + if err == nil || !strings.Contains( + err.Error(), + "conflicting code intelligence FTS rows share identity", + ) { + t.Fatalf("expected conflicting identity rejection, got %v", err) + } + + readOnly, err := sql.Open("duckdb", path+"?access_mode=READ_ONLY") + if err != nil { + t.Fatalf("open rejected legacy store read-only: %v", err) + } + defer readOnly.Close() + + var rows int + if err = readOnly.QueryRowContext( + ctx, + "SELECT COUNT(*) FROM code_intel_fts WHERE fts_id = 'legacy:conflict'", + ).Scan(&rows); err != nil { + t.Fatalf("count retained conflicting identities: %v", err) + } + if rows != 2 { + t.Fatalf("conflicting migration retained %d rows, want 2", rows) + } +} + +func TestRepeatedCodeIndexWriteKeepsSearchAndRelationshipsStable(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, DefaultDBPath(t.TempDir())) + if err != nil { + t.Fatalf("open repeated-index store: %v", err) + } + defer store.Close() + + file := CodeFile{ + Path: "pkg/replayed.go", + Language: "go", + ContentHash: "file-one", + SizeBytes: 30, + LineCount: 3, + IndexedAtUTC: "2026-08-30T00:00:00Z", + } + chunk := CodeChunk{ + ID: "chunk-replayed", + Path: file.Path, + Language: "go", + NodeKind: "function", + SymbolKind: "function", + SymbolName: "Replayed", + SymbolPath: "Replayed", + StartByte: 0, + EndByte: 30, + StartLine: 1, + EndLine: 3, + ContentHash: "chunk-one", + SearchText: "alpha beta", + RawText: "func Replayed() {}", + } + edge := CodeEdge{ + ID: "edge-replayed", + Kind: "calls", + Path: file.Path, + SourceChunkID: chunk.ID, + TargetName: "Other", + } + if err = store.ReplaceCodeFileIndex( + ctx, + file, + []CodeChunk{chunk}, + []CodeEdge{edge}, + ); err != nil { + t.Fatalf("write initial code index: %v", err) + } + + transaction, err := store.Database().BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin AST link fixture: %v", err) + } + if err = insertASTFindingLink(ctx, transaction, ASTFindingLink{ + ID: "link-replayed", + FindingKind: "sarif_result", + FindingID: "result-replayed", + ChunkID: chunk.ID, + Path: file.Path, + SymbolPath: chunk.SymbolPath, + ContentHash: chunk.ContentHash, + }); err != nil { + _ = transaction.Rollback() + t.Fatalf("insert AST link fixture: %v", err) + } + if err = transaction.Commit(); err != nil { + t.Fatalf("commit AST link fixture: %v", err) + } + + file.ContentHash = "file-two" + chunk.ContentHash = "chunk-two" + chunk.SearchText = "alpha gamma" + if err = store.ReplaceCodeFileIndex( + ctx, + file, + []CodeChunk{chunk}, + []CodeEdge{edge}, + ); err != nil { + t.Fatalf("replay updated code index: %v", err) + } + + stats, err := store.Stats(ctx) + if err != nil { + t.Fatalf("read replayed-index stats: %v", err) + } + if stats.CodeChunks != 1 || stats.CodeEdges != 1 || stats.ASTFindingLinks != 1 || + stats.FtsRows != 1 || stats.SearchTermRows != 2 || + stats.FtsDuplicateRows != 0 || stats.SearchTermDuplicateRows != 0 { + t.Fatalf("unexpected replayed-index stats: %#v", stats) + } + + var staleTerms int + if err = store.Database().QueryRowContext( + ctx, + `SELECT COUNT(*) FROM code_intel_search_terms + WHERE fts_id = 'code_chunk:chunk-replayed:' AND term = 'beta'`, + ).Scan(&staleTerms); err != nil { + t.Fatalf("count stale replayed search terms: %v", err) + } + if staleTerms != 0 { + t.Fatalf("replayed index retained %d stale search terms", staleTerms) + } +} + +func openLegacySearchIdentityFixture( + t *testing.T, + ctx context.Context, +) (string, *sql.DB) { + t.Helper() + + path := DefaultDBPath(t.TempDir()) + store, err := Open(ctx, path) + if err != nil { + t.Fatalf("initialize legacy search identity fixture: %v", err) + } + if err = store.Close(); err != nil { + t.Fatalf("close initialized search identity fixture: %v", err) + } + + database, err := sql.Open("duckdb", path) + if err != nil { + t.Fatalf("open legacy search identity fixture: %v", err) + } + for _, statement := range []string{ + "DROP INDEX IF EXISTS idx_code_intel_fts_id_unique", + "DROP INDEX IF EXISTS idx_code_intel_search_terms_unique", + "UPDATE schema_metadata SET value = '1' WHERE key = 'schema_version'", + } { + if _, err = database.ExecContext(ctx, statement); err != nil { + _ = database.Close() + t.Fatalf("prepare legacy search identity fixture: %v", err) + } + } + + return path, database +} diff --git a/go/internal/codeintel/store.go b/go/internal/codeintel/store.go index ef21e675..e4b5df9b 100644 --- a/go/internal/codeintel/store.go +++ b/go/internal/codeintel/store.go @@ -19,7 +19,7 @@ import ( const ( sourcePathClauseCapacityFactor = 2 sourcePathQueryArgFactor = 4 - schemaVersion = 1 + schemaVersion = 2 storeDirMode = 0o700 storeLockWait = 2 * time.Second storeLockRetryInterval = 100 * time.Millisecond @@ -33,34 +33,37 @@ type Store struct { type storeOpenFunc func(context.Context, string) (*Store, error) type Stats struct { - Traces int `json:"traces"` - HookEvents int `json:"hook_events"` - HookDecisions int `json:"hook_decisions"` - HookTargets int `json:"hook_targets"` - HookReviews int `json:"hook_reviews"` - ProxySessions int `json:"proxy_sessions"` - ProxyEvents int `json:"proxy_events"` - ProxyTransforms int `json:"proxy_transforms"` - Findings int `json:"findings"` - Files int `json:"files"` - CodeChunks int `json:"code_chunks"` - CodeEdges int `json:"code_edges"` - GitFileSignals int `json:"git_file_signals"` - GitCoChanges int `json:"git_cochanges"` - CodeHealthSnapshots int `json:"code_health_snapshots"` - CodeHealthTargets int `json:"code_health_targets"` - CodeHealthCoverage int `json:"code_health_coverage"` - ASTFindingLinks int `json:"ast_finding_links"` - Decisions int `json:"decisions"` - DecisionLinks int `json:"decision_links"` - Remediations int `json:"remediations"` - RemediationEvents int `json:"remediation_events"` - SARIFRuns int `json:"sarif_runs"` - SARIFResults int `json:"sarif_results"` - RemediationOutcomes int `json:"remediation_outcomes"` - EmbeddingRecords int `json:"embedding_records"` - FtsRows int `json:"fts_rows"` - SchemaVersion int `json:"schema_version"` + Traces int `json:"traces"` + HookEvents int `json:"hook_events"` + HookDecisions int `json:"hook_decisions"` + HookTargets int `json:"hook_targets"` + HookReviews int `json:"hook_reviews"` + ProxySessions int `json:"proxy_sessions"` + ProxyEvents int `json:"proxy_events"` + ProxyTransforms int `json:"proxy_transforms"` + Findings int `json:"findings"` + Files int `json:"files"` + CodeChunks int `json:"code_chunks"` + CodeEdges int `json:"code_edges"` + GitFileSignals int `json:"git_file_signals"` + GitCoChanges int `json:"git_cochanges"` + CodeHealthSnapshots int `json:"code_health_snapshots"` + CodeHealthTargets int `json:"code_health_targets"` + CodeHealthCoverage int `json:"code_health_coverage"` + ASTFindingLinks int `json:"ast_finding_links"` + Decisions int `json:"decisions"` + DecisionLinks int `json:"decision_links"` + Remediations int `json:"remediations"` + RemediationEvents int `json:"remediation_events"` + SARIFRuns int `json:"sarif_runs"` + SARIFResults int `json:"sarif_results"` + RemediationOutcomes int `json:"remediation_outcomes"` + EmbeddingRecords int `json:"embedding_records"` + FtsRows int `json:"fts_rows"` + FtsDuplicateRows int `json:"fts_duplicate_rows"` + SearchTermRows int `json:"search_term_rows"` + SearchTermDuplicateRows int `json:"search_term_duplicate_rows"` + SchemaVersion int `json:"schema_version"` } type RowPruneSummary struct { @@ -533,21 +536,27 @@ func migrateStore(ctx context.Context, database *sql.DB) error { } } + err := deduplicateSearchIdentity(ctx, database) + if err != nil { + return err + } + for _, statement := range indexSchemaStatements() { - _, err := database.ExecContext(ctx, statement) + _, err = database.ExecContext(ctx, statement) if err != nil { return fmt.Errorf("migrate code intelligence indexes: %w", err) } } - err := backfillSearchTerms(ctx, database) + err = backfillSearchTerms(ctx, database) if err != nil { return err } _, err = database.ExecContext( ctx, - "INSERT OR REPLACE INTO schema_metadata(key, value) VALUES('schema_version', ?)", + `INSERT INTO schema_metadata(key, value) VALUES('schema_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, schemaVersion, ) if err != nil { @@ -697,5 +706,26 @@ func statCountQueries(stats *Stats) []statCountQuery { query: "SELECT COUNT(*) FROM code_intel_fts", target: &stats.FtsRows, }, + { + name: "duplicate code_intel_fts identities", + query: `SELECT + (SELECT COUNT(*) FROM code_intel_fts) - + (SELECT COUNT(*) FROM (SELECT DISTINCT fts_id FROM code_intel_fts) AS identities)`, + target: &stats.FtsDuplicateRows, + }, + { + name: "code_intel_search_terms", + query: "SELECT COUNT(*) FROM code_intel_search_terms", + target: &stats.SearchTermRows, + }, + { + name: "duplicate code_intel_search_terms identities", + query: `SELECT + (SELECT COUNT(*) FROM code_intel_search_terms) - + (SELECT COUNT(*) FROM ( + SELECT DISTINCT term, fts_id FROM code_intel_search_terms + ) AS identities)`, + target: &stats.SearchTermDuplicateRows, + }, } } diff --git a/go/internal/codeintel/store_migration.go b/go/internal/codeintel/store_migration.go index 82c5302e..f48ff253 100644 --- a/go/internal/codeintel/store_migration.go +++ b/go/internal/codeintel/store_migration.go @@ -329,7 +329,8 @@ func migrationTablesVerified(tables []StoreMigrationTable) bool { for _, table := range tables { if !table.SourceRowsVerified || - table.SourceRows != table.ImportedRows+table.MatchedRows { + table.SourceRows != table.ImportedRows+ + table.MatchedRows+table.DeduplicatedRows { return false } } diff --git a/go/internal/codeintel/store_migration_manifest.go b/go/internal/codeintel/store_migration_manifest.go index 2802d971..a7421a57 100644 --- a/go/internal/codeintel/store_migration_manifest.go +++ b/go/internal/codeintel/store_migration_manifest.go @@ -15,7 +15,7 @@ import ( ) const ( - storeMigrationManifestKind = "code_intel.store_migration.v1" + storeMigrationManifestKind = "code_intel.store_migration.v2" storeMigrationFileMode = 0o600 ) @@ -28,6 +28,7 @@ type StoreMigrationTable struct { SourceRows int64 `json:"source_rows"` ImportedRows int64 `json:"imported_rows"` MatchedRows int64 `json:"matched_rows"` + DeduplicatedRows int64 `json:"deduplicated_rows,omitempty"` DestinationRows int64 `json:"destination_rows"` SourceRowsVerified bool `json:"source_rows_verified"` } diff --git a/go/internal/codeintel/store_migration_rows.go b/go/internal/codeintel/store_migration_rows.go index 3aaba11f..905bab6b 100644 --- a/go/internal/codeintel/store_migration_rows.go +++ b/go/internal/codeintel/store_migration_rows.go @@ -255,6 +255,7 @@ func inspectMigrationTable( ctx, destination, destinationTable, + spec, ) if err != nil { return migrationTableBaseline{}, err @@ -308,7 +309,14 @@ func finishMigrationTable( } importedRows := destinationRows - baseline.destinationRowsBefore - if baseline.sourceRows != importedRows+baseline.matchedRows { + deduplicatedRows := int64(0) + + if spec.deduplicateRows { + deduplicatedRows = baseline.sourceRows - importedRows - baseline.matchedRows + } + + if deduplicatedRows < 0 || + baseline.sourceRows != importedRows+baseline.matchedRows+deduplicatedRows { return StoreMigrationTable{}, fmt.Errorf( "%w: row accounting mismatch in %s", errStoreMigrationIntegrity, @@ -323,6 +331,7 @@ func finishMigrationTable( SourceRows: baseline.sourceRows, ImportedRows: importedRows, MatchedRows: baseline.matchedRows, + DeduplicatedRows: deduplicatedRows, DestinationRows: destinationRows, SourceRowsVerified: true, }, nil @@ -337,6 +346,7 @@ func validateMigrationKeyConsistency( ) error { keys := quoteMigrationIdentifiers(spec.keyColumns) variantChecks := make([]string, 0, len(columns)) + rowPredicate := migrationRowPredicate(spec, "") for _, column := range migrationColumnIdentifiers(columns) { variantChecks = append( @@ -352,9 +362,10 @@ func validateMigrationKeyConsistency( // #nosec G201 -- table, keys, and columns come from the validated schema inventory. query := fmt.Sprintf( - "SELECT COUNT(*) FROM (SELECT %s FROM %s GROUP BY %s HAVING %s)", + "SELECT COUNT(*) FROM (SELECT %s FROM %s WHERE %s GROUP BY %s HAVING %s)", strings.Join(keys, ", "), table, + rowPredicate, strings.Join(keys, ", "), strings.Join(variantChecks, " OR "), ) @@ -388,26 +399,32 @@ func migrationDuplicateCounts( ) (int64, int64, error) { join := migrationKeyEquality("source", "destination", spec.keyColumns) equal := migrationColumnEquality("source", "destination", columns) + sourcePredicate := migrationRowPredicate(spec, "source") + destinationPredicate := migrationRowPredicate(spec, "destination") // #nosec G201 -- identifiers come from the validated schema inventory. query := fmt.Sprintf( `SELECT COALESCE(SUM(CASE WHEN EXISTS ( - SELECT 1 FROM %s AS destination WHERE %s + SELECT 1 FROM %s AS destination WHERE %s AND %s ) THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN EXISTS ( - SELECT 1 FROM %s AS destination WHERE %s + SELECT 1 FROM %s AS destination WHERE %s AND %s ) AND NOT EXISTS ( - SELECT 1 FROM %s AS destination WHERE %s + SELECT 1 FROM %s AS destination WHERE %s AND %s ) THEN 1 ELSE 0 END), 0) - FROM %s AS source`, + FROM %s AS source WHERE %s`, destinationTable, + destinationPredicate, equal, destinationTable, + destinationPredicate, join, destinationTable, + destinationPredicate, equal, sourceTable, + sourcePredicate, ) var matchedRows, conflictRows int64 @@ -430,20 +447,31 @@ func insertMissingMigrationRows( ) error { columnNames := migrationColumnIdentifiers(columns) selectedColumns := qualifiedMigrationIdentifiers("source", columnNames) + selectModifier := "" + + if spec.deduplicateRows { + selectModifier = "DISTINCT " + } + join := migrationKeyEquality("source", "destination", spec.keyColumns) + sourcePredicate := migrationRowPredicate(spec, "source") + destinationPredicate := migrationRowPredicate(spec, "destination") // #nosec G201 -- identifiers come from the validated schema inventory. query := fmt.Sprintf( `INSERT INTO %s(%s) - SELECT %s FROM %s AS source - WHERE NOT EXISTS ( - SELECT 1 FROM %s AS destination WHERE %s + SELECT %s%s FROM %s AS source + WHERE %s AND NOT EXISTS ( + SELECT 1 FROM %s AS destination WHERE %s AND %s )`, destinationTable, strings.Join(columnNames, ", "), + selectModifier, strings.Join(selectedColumns, ", "), sourceTable, + sourcePredicate, destinationTable, + destinationPredicate, join, ) @@ -512,9 +540,10 @@ func migrationTableRowCount( ctx context.Context, database migrationQueryer, table string, + spec migrationTableSpec, ) (int64, error) { // #nosec G201 -- table comes from the validated schema inventory. - query := "SELECT COUNT(*) FROM " + table + query := "SELECT COUNT(*) FROM " + table + " WHERE " + migrationRowPredicate(spec, "") var count int64 @@ -563,9 +592,10 @@ func queryMigrationRows( ) (*sql.Rows, error) { // #nosec G201 -- table and columns come from the validated schema inventory. query := fmt.Sprintf( - "SELECT %s FROM %s ORDER BY %s", + "SELECT %s FROM %s WHERE %s ORDER BY %s", strings.Join(migrationColumnIdentifiers(columns), ", "), table, + migrationRowPredicate(spec, ""), strings.Join(quoteMigrationIdentifiers(spec.keyColumns), ", "), ) @@ -577,6 +607,19 @@ func queryMigrationRows( return rows, nil } +func migrationRowPredicate(spec migrationTableSpec, alias string) string { + if !spec.excludeVersionRow { + return "TRUE" + } + + column := quoteMigrationIdentifier("key") + if alias != "" { + column = alias + "." + column + } + + return column + " != 'schema_version'" +} + func hashMigrationRows( rows *sql.Rows, columns []migrationColumn, diff --git a/go/internal/codeintel/store_migration_schema.go b/go/internal/codeintel/store_migration_schema.go index c581c248..f9b08a5c 100644 --- a/go/internal/codeintel/store_migration_schema.go +++ b/go/internal/codeintel/store_migration_schema.go @@ -16,6 +16,8 @@ type migrationTableSpec struct { name string keyColumns []string logicalPrimaryKey bool + deduplicateRows bool + excludeVersionRow bool } type migrationColumn struct { @@ -30,7 +32,11 @@ type migrationSchema map[string][]migrationColumn func migrationTableSpecs() []migrationTableSpec { return []migrationTableSpec{ {name: "code_intel_events", keyColumns: []string{"event_id"}}, - {name: "schema_metadata", keyColumns: []string{"key"}}, + { + name: "schema_metadata", + keyColumns: []string{"key"}, + excludeVersionRow: true, + }, {name: "traces", keyColumns: []string{"trace_id"}}, {name: "findings", keyColumns: []string{"finding_id"}}, {name: "finding_occurrences", keyColumns: []string{"trace_id", "ordinal"}}, @@ -80,11 +86,13 @@ func migrationTableSpecs() []migrationTableSpec { name: "code_intel_fts", keyColumns: []string{"fts_id"}, logicalPrimaryKey: true, + deduplicateRows: true, }, { name: "code_intel_search_terms", keyColumns: []string{"fts_id", "term"}, logicalPrimaryKey: true, + deduplicateRows: true, }, } } @@ -132,9 +140,9 @@ func validateMigrationSchemaVersion(ctx context.Context, database *sql.DB) error return fmt.Errorf("parse code-intel migration schema version %q: %w", rawVersion, err) } - if version != schemaVersion { + if version < 1 || version > schemaVersion { return fmt.Errorf( - "%w: schema version is %d, expected %d", + "%w: schema version is %d, supported range is 1 through %d", errStoreMigrationIntegrity, version, schemaVersion, diff --git a/go/internal/codeintel/store_migration_test.go b/go/internal/codeintel/store_migration_test.go index adfdce0c..31c95b74 100644 --- a/go/internal/codeintel/store_migration_test.go +++ b/go/internal/codeintel/store_migration_test.go @@ -119,7 +119,7 @@ func TestMigrateStoreRejectsUnequalDuplicateRows(t *testing.T) { } } -func TestMigrateStorePreservesEqualLogicalKeyDuplicates(t *testing.T) { +func TestMigrateStorePreservesAndDeduplicatesEqualLogicalKeyRows(t *testing.T) { tests := []struct { name string sourceCopies int @@ -170,25 +170,45 @@ func TestMigrateStorePreservesEqualLogicalKeyDuplicates(t *testing.T) { t.Fatalf("migrate equal logical-key duplicates: %v", err) } - for _, tableName := range []string{ + lshEvidence := migrationTableEvidence(t, result.Manifest.Tables, "lsh_bands") + if lshEvidence.SourceRows != int64(test.sourceCopies) || + lshEvidence.ImportedRows != test.wantImported || + lshEvidence.MatchedRows != test.wantMatched || + lshEvidence.DestinationRows != test.wantDestinationRows { + t.Fatalf("unexpected lsh_bands evidence: %#v", lshEvidence) + } + assertMigrationTableRowCount( + t, + ctx, + destinationPath, "lsh_bands", + test.wantDestinationRows, + ) + + for _, tableName := range []string{ "code_intel_fts", "code_intel_search_terms", } { evidence := migrationTableEvidence(t, result.Manifest.Tables, tableName) + wantImported := min(test.wantImported, 1) + wantDestinationRows := min(test.wantDestinationRows, 1) + wantDeduplicated := int64(0) + if test.destinationCopies == 0 { + wantDeduplicated = int64(test.sourceCopies) - wantImported + } if evidence.SourceRows != int64(test.sourceCopies) || - evidence.ImportedRows != test.wantImported || + evidence.ImportedRows != wantImported || evidence.MatchedRows != test.wantMatched || - evidence.DestinationRows != test.wantDestinationRows { + evidence.DeduplicatedRows != wantDeduplicated || + evidence.DestinationRows != wantDestinationRows { t.Fatalf("unexpected %s evidence: %#v", tableName, evidence) } - assertMigrationTableRowCount( t, ctx, destinationPath, tableName, - test.wantDestinationRows, + wantDestinationRows, ) } }) @@ -222,9 +242,15 @@ func TestMigrateStoreRejectsConflictingLogicalKeyVariants(t *testing.T) { SourcePath: sourcePath, DestinationPath: destinationPath, }) - if err == nil || - !strings.Contains(err.Error(), "conflicting row variants") || - !strings.Contains(err.Error(), tableName) { + conflictingVariant := err != nil && + strings.Contains(err.Error(), "conflicting row variants") && + strings.Contains(err.Error(), tableName) + conflictingUpgrade := err != nil && tableName == "code_intel_fts" && + strings.Contains( + err.Error(), + "conflicting code intelligence FTS rows share identity", + ) + if !conflictingVariant && !conflictingUpgrade { t.Fatalf("expected %s conflict rejection, got %v", tableName, err) } @@ -454,8 +480,25 @@ func insertMigrationLogicalKeySupport( database *sql.DB, ) { t.Helper() + for _, indexName := range []string{ + "idx_code_intel_fts_id_unique", + "idx_code_intel_search_terms_unique", + } { + _, err := database.ExecContext(ctx, "DROP INDEX IF EXISTS "+indexName) + if err != nil { + t.Fatalf("drop v2 logical-key index %s: %v", indexName, err) + } + } _, err := database.ExecContext( + ctx, + "UPDATE schema_metadata SET value = '1' WHERE key = 'schema_version'", + ) + if err != nil { + t.Fatalf("mark logical-key fixture as schema v1: %v", err) + } + + _, err = database.ExecContext( ctx, `INSERT OR IGNORE INTO code_files( path, language, content_hash, size_bytes, line_count, indexed_at_utc @@ -523,9 +566,13 @@ func recordTestMigrationIdentity( repositoryRoot string, ) { t.Helper() - store := openMigrationTestStore(t, ctx, databasePath) + database, err := sql.Open("duckdb", databasePath) + if err != nil { + t.Fatalf("open test migration identity database: %v", err) + } + defer database.Close() - _, err := store.Database().ExecContext( + _, err = database.ExecContext( ctx, `INSERT OR REPLACE INTO schema_metadata(key, value) VALUES ('repository_identity', ?)`, @@ -534,8 +581,6 @@ func recordTestMigrationIdentity( if err != nil { t.Fatalf("record test migration identity: %v", err) } - - closeMigrationTestStore(t, store) } func assertMigrationCodeFileHash( diff --git a/go/internal/codeintel/write.go b/go/internal/codeintel/write.go index d90fbd88..0c5b5d2c 100644 --- a/go/internal/codeintel/write.go +++ b/go/internal/codeintel/write.go @@ -995,11 +995,22 @@ func upsertCodeFile( ) error { _, err := transaction.ExecContext( ctx, - `INSERT OR REPLACE INTO code_files( + `INSERT INTO code_files( path, language, content_hash, parser_name, parser_version, source_mtime_utc, deleted_at_utc, size_bytes, line_count, indexed_at_utc, stale_reason - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path) DO UPDATE SET + language = excluded.language, + content_hash = excluded.content_hash, + parser_name = excluded.parser_name, + parser_version = excluded.parser_version, + source_mtime_utc = excluded.source_mtime_utc, + deleted_at_utc = excluded.deleted_at_utc, + size_bytes = excluded.size_bytes, + line_count = excluded.line_count, + indexed_at_utc = excluded.indexed_at_utc, + stale_reason = excluded.stale_reason`, file.Path, file.Language, file.ContentHash, @@ -1022,11 +1033,29 @@ func upsertCodeFile( func insertCodeChunk(ctx context.Context, transaction *sql.Tx, chunk CodeChunk) error { _, inlineErrO := transaction.ExecContext( ctx, - `INSERT OR REPLACE INTO code_chunks( + `INSERT INTO code_chunks( chunk_id, path, language, node_kind, symbol_kind, symbol_name, symbol_path, parent_symbol_path, parent_chunk_id, start_byte, end_byte, start_line, end_line, content_hash, normalized_hash, minhash_sig, search_text, raw_text - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chunk_id) DO UPDATE SET + path = excluded.path, + language = excluded.language, + node_kind = excluded.node_kind, + symbol_kind = excluded.symbol_kind, + symbol_name = excluded.symbol_name, + symbol_path = excluded.symbol_path, + parent_symbol_path = excluded.parent_symbol_path, + parent_chunk_id = excluded.parent_chunk_id, + start_byte = excluded.start_byte, + end_byte = excluded.end_byte, + start_line = excluded.start_line, + end_line = excluded.end_line, + content_hash = excluded.content_hash, + normalized_hash = excluded.normalized_hash, + minhash_sig = excluded.minhash_sig, + search_text = excluded.search_text, + raw_text = excluded.raw_text`, chunk.ID, chunk.Path, chunk.Language, @@ -1065,11 +1094,21 @@ func insertCodeChunk(ctx context.Context, transaction *sql.Tx, chunk CodeChunk) func insertCodeEdge(ctx context.Context, transaction *sql.Tx, edge CodeEdge) error { _, inlineErrP := transaction.ExecContext( ctx, - `INSERT OR REPLACE INTO code_edges( + `INSERT INTO code_edges( edge_id, edge_kind, path, source_chunk_id, target_path, target_chunk_id, target_symbol_path, target_name, provenance_class, raw_text - ) VALUES (?, ?, ?, NULLIF(?, ''), ?, NULLIF(?, ''), ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, NULLIF(?, ''), ?, NULLIF(?, ''), ?, ?, ?, ?) + ON CONFLICT(edge_id) DO UPDATE SET + edge_kind = excluded.edge_kind, + path = excluded.path, + source_chunk_id = excluded.source_chunk_id, + target_path = excluded.target_path, + target_chunk_id = excluded.target_chunk_id, + target_symbol_path = excluded.target_symbol_path, + target_name = excluded.target_name, + provenance_class = excluded.provenance_class, + raw_text = excluded.raw_text`, edge.ID, edge.Kind, edge.Path, @@ -1292,7 +1331,16 @@ func insertFTS(ctx context.Context, transaction *sql.Tx, row ftsRow) error { ctx, `INSERT INTO code_intel_fts( fts_id, kind, record_id, trace_id, policy_id, skill_id, path, message, search_text - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(fts_id) DO UPDATE SET + kind = excluded.kind, + record_id = excluded.record_id, + trace_id = excluded.trace_id, + policy_id = excluded.policy_id, + skill_id = excluded.skill_id, + path = excluded.path, + message = excluded.message, + search_text = excluded.search_text`, rowID, row.Kind, row.RecordID, @@ -1307,7 +1355,7 @@ func insertFTS(ctx context.Context, transaction *sql.Tx, row ftsRow) error { return fmt.Errorf("insert code intelligence FTS row: %w", err) } - return insertSearchTerms(ctx, transaction, rowID, row.SearchText) + return synchronizeSearchTerms(ctx, transaction, rowID, row.SearchText) } func ftsRowID(row ftsRow) string { @@ -1323,7 +1371,14 @@ func insertSearchTerms( for _, term := range searchTerms(text) { _, err := transaction.ExecContext( ctx, - `INSERT INTO code_intel_search_terms(term, fts_id) VALUES (?, ?)`, + `INSERT INTO code_intel_search_terms(term, fts_id) + SELECT ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM code_intel_search_terms + WHERE term = ? AND fts_id = ? + )`, + term, + rowID, term, rowID, ) @@ -1335,6 +1390,79 @@ func insertSearchTerms( return nil } +func synchronizeSearchTerms( + ctx context.Context, + transaction *sql.Tx, + rowID string, + text string, +) error { + desired := searchTerms(text) + desiredSet := make(map[string]bool, len(desired)) + + for _, term := range desired { + desiredSet[term] = true + } + + existing, err := existingSearchTerms(ctx, transaction, rowID) + if err != nil { + return err + } + + for _, term := range existing { + if desiredSet[term] { + continue + } + + _, err = transaction.ExecContext( + ctx, + "DELETE FROM code_intel_search_terms WHERE term = ? AND fts_id = ?", + term, + rowID, + ) + if err != nil { + return fmt.Errorf("delete stale code intelligence search term: %w", err) + } + } + + return insertSearchTerms(ctx, transaction, rowID, text) +} + +func existingSearchTerms( + ctx context.Context, + transaction *sql.Tx, + rowID string, +) ([]string, error) { + rows, err := transaction.QueryContext( + ctx, + "SELECT term FROM code_intel_search_terms WHERE fts_id = ?", + rowID, + ) + if err != nil { + return nil, fmt.Errorf("query existing code intelligence search terms: %w", err) + } + defer rows.Close() + + existing := []string{} + + for rows.Next() { + var term string + + err = rows.Scan(&term) + if err != nil { + return nil, fmt.Errorf("scan existing code intelligence search term: %w", err) + } + + existing = append(existing, term) + } + + err = rows.Err() + if err != nil { + return nil, fmt.Errorf("iterate existing code intelligence search terms: %w", err) + } + + return existing, nil +} + func remediationSearchText(remediation agentmsg.Remediation) string { return strings.Join(compactStrings([]string{ remediation.PolicyID, diff --git a/go/internal/hookrunnercli/export.go b/go/internal/hookrunnercli/export.go index 5b7ecab2..438ca96c 100644 --- a/go/internal/hookrunnercli/export.go +++ b/go/internal/hookrunnercli/export.go @@ -13,6 +13,14 @@ func Run(args []string) int { return 1 } + restoreCacheEnvironment, err := prepareHookProcessCacheEnvironment(repoRoot()) + if err != nil { + writef(os.Stderr, "FATAL: prepare hook cache environment: %v\n", err) + + return 1 + } + defer restoreCacheEnvironment() + cfg, err := loadConfig() if err != nil { writef(os.Stderr, "FATAL: %v\n", err) diff --git a/go/internal/hookrunnercli/external_tool.go b/go/internal/hookrunnercli/external_tool.go index 92d7c6da..2fecf4d8 100644 --- a/go/internal/hookrunnercli/external_tool.go +++ b/go/internal/hookrunnercli/external_tool.go @@ -317,6 +317,59 @@ func externalToolCacheEnv(root string) (externalToolCacheEnvironment, error) { }, nil } +// prepareHookProcessCacheEnvironment projects the consumer-owned cache roots +// onto the hook runner itself. Nested pre-commit languages can start before an +// individual external-tool request is constructed; setting these variables at +// the command boundary prevents uv, Go, Cargo, and linters from falling back +// to an unwritable operator cache. The returned closure restores the exact +// caller environment for in-process tests and embedded invocations. +func prepareHookProcessCacheEnvironment(root string) (func(), error) { + environment, err := externalToolCacheEnv(root) + if err != nil { + return nil, err + } + + type priorValue struct { + value string + existed bool + } + + prior := map[string]priorValue{} + + for _, item := range environment.items() { + name, value, found := strings.Cut(item, "=") + if !found { + continue + } + + previous, existed := os.LookupEnv(name) + prior[name] = priorValue{value: previous, existed: existed} + + setErr := os.Setenv(name, value) + if setErr != nil { + for restoreName, restoreValue := range prior { + if restoreValue.existed { + _ = os.Setenv(restoreName, restoreValue.value) + } else { + _ = os.Unsetenv(restoreName) + } + } + + return nil, fmt.Errorf("set hook process cache environment %s: %w", name, setErr) + } + } + + return func() { + for name, previous := range prior { + if previous.existed { + _ = os.Setenv(name, previous.value) + } else { + _ = os.Unsetenv(name) + } + } + }, nil +} + func (environment externalToolCacheEnvironment) overrides(name string) bool { return environment.value(name) != "" } diff --git a/go/internal/hookrunnercli/external_tool_internal_test.go b/go/internal/hookrunnercli/external_tool_internal_test.go index cfaa0bf5..02380622 100644 --- a/go/internal/hookrunnercli/external_tool_internal_test.go +++ b/go/internal/hookrunnercli/external_tool_internal_test.go @@ -10,8 +10,34 @@ import ( "slices" "strings" "testing" + + "blackcat.ca/coding-ethos/go/internal/testlock" ) +func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVCache(t *testing.T) { + testlock.ProcessState(t, "hook-process-cache-environment") + + root := t.TempDir() + t.Setenv("UV_CACHE_DIR", "/previous/uv-cache") + restore, err := prepareHookProcessCacheEnvironment(root) + if err != nil { + t.Fatalf("prepareHookProcessCacheEnvironment: %v", err) + } + + want := filepath.Join(root, ".coding-ethos", "cache", "uv") + if got := os.Getenv("UV_CACHE_DIR"); got != want { + t.Fatalf("UV_CACHE_DIR = %q, want %q", got, want) + } + if info, statErr := os.Stat(want); statErr != nil || !info.IsDir() { + t.Fatalf("UV cache directory is not usable: info=%v error=%v", info, statErr) + } + + restore() + if got := os.Getenv("UV_CACHE_DIR"); got != "/previous/uv-cache" { + t.Fatalf("restored UV_CACHE_DIR = %q", got) + } +} + func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { shimDir := t.TempDir() shimPath := filepath.Join(shimDir, "git") diff --git a/go/internal/hookrunnercli/git_hook.go b/go/internal/hookrunnercli/git_hook.go index 9aa9195f..a2290828 100644 --- a/go/internal/hookrunnercli/git_hook.go +++ b/go/internal/hookrunnercli/git_hook.go @@ -388,11 +388,6 @@ func hookGroupResultFilePath(path string) (string, bool) { return "", false } - tempDir, err := resolvedPath(os.TempDir()) - if err != nil { - return "", false - } - absolutePath, err := filepath.Abs(cleanPath) if err != nil { return "", false @@ -403,16 +398,46 @@ func hookGroupResultFilePath(path string) (string, bool) { return "", false } - relativePath, err := filepath.Rel(tempDir, resolvedTarget) - if err != nil || - relativePath == ".." || - strings.HasPrefix(relativePath, ".."+string(os.PathSeparator)) { + if !pathWithinTemporaryRoots(resolvedTarget) { return "", false } return absolutePath, true } +func pathWithinTemporaryRoots(path string) bool { + for _, root := range temporaryRoots() { + relativePath, err := filepath.Rel(root, path) + if err == nil && + relativePath != ".." && + !strings.HasPrefix(relativePath, ".."+string(os.PathSeparator)) { + return true + } + } + + return false +} + +func temporaryRoots() []string { + roots := []string{} + + for _, candidate := range []string{os.TempDir(), os.Getenv("GOTMPDIR")} { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + + resolved, err := resolvedPath(candidate) + if err != nil || slices.Contains(roots, resolved) { + continue + } + + roots = append(roots, resolved) + } + + return roots +} + func resolvedPath(path string) (string, error) { absolutePath, err := filepath.Abs(filepath.Clean(path)) if err != nil { diff --git a/go/internal/hookrunnercli/git_hook_internal_test.go b/go/internal/hookrunnercli/git_hook_internal_test.go index bbd39b50..c0dc8ba0 100644 --- a/go/internal/hookrunnercli/git_hook_internal_test.go +++ b/go/internal/hookrunnercli/git_hook_internal_test.go @@ -246,6 +246,25 @@ func TestHookGroupResultFileRoundTrip(t *testing.T) { } } +func TestHookGroupResultFilePathAcceptsGoTempDir(t *testing.T) { + goTempDir := filepath.Join(t.TempDir(), "go-temp") + if err := os.MkdirAll(goTempDir, 0o700); err != nil { + t.Fatalf("create Go temp dir: %v", err) + } + t.Setenv("GOTMPDIR", goTempDir) + + resultPath := filepath.Join(goTempDir, "result.json") + cleanPath, ok := hookGroupResultFilePath(resultPath) + if !ok || cleanPath != resultPath { + t.Fatalf( + "hookGroupResultFilePath() = %q, %t; want %q, true", + cleanPath, + ok, + resultPath, + ) + } +} + func TestReadHookGroupResultFileRejectsNonTempPath(t *testing.T) { t.Parallel() diff --git a/go/internal/hooks/gate_exit_status.go b/go/internal/hooks/gate_exit_status.go new file mode 100644 index 00000000..eaf9fc4e --- /dev/null +++ b/go/internal/hooks/gate_exit_status.go @@ -0,0 +1,638 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package hooks + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + + "blackcat.ca/coding-ethos/go/internal/shellparse" +) + +const ( + gateExitStatusPolicyID = "shell.required_gate_exit_status" + maxGateShellDepth = 3 + preCommitExecutable = "pre-commit" + pytestExecutable = "pytest" + + gateBackgroundReason = "Required repository gates must return their " + + "terminal status, not run in the background." + gateFallbackReason = "Required repository gate failure cannot be hidden " + + "by a shell fallback." + gatePipelineReason = "Required repository gate pipelines must enable " + + "pipefail so the gate status remains authoritative." + gateSequenceReason = "Commands after a required repository gate must " + + "capture and return the gate's exact exit status." +) + +func requiredGateExitStatusRouteFor(event Event) InspectionRoute { + if event.HookEventName != eventPreToolUse || event.ToolName != toolBash { + return InspectionRoute{} + } + + reason, masked := maskedRequiredGateStatus(event.Command(), false, 0) + if !masked { + return InspectionRoute{} + } + + return InspectionRoute{ + BlockPolicyID: gateExitStatusPolicyID, + Reason: reason, + Block: true, + } +} + +type gateShell struct { + segments [][]string + operators []string +} + +func maskedRequiredGateStatus( + command string, + inheritedPipefail bool, + depth int, +) (string, bool) { + if depth > maxGateShellDepth || strings.TrimSpace(command) == "" { + return "", false + } + + parsed, err := parseGateShell(command) + if err != nil { + return "", false + } + + pipefail := inheritedPipefail || parsed.enablesPipefail() + + for index := range parsed.segments { + reason, masked := parsed.maskedSegmentStatus(index, pipefail, depth) + if masked { + return reason, true + } + } + + return "", false +} + +func (parsed gateShell) maskedSegmentStatus( + index int, + pipefail bool, + depth int, +) (string, bool) { + argv := gateExecutableArgv(parsed.segments[index]) + nested, nestedPipefail, nestedFound := nestedShellScript(argv) + + if nestedFound { + reason, masked := maskedRequiredGateStatus( + nested, + pipefail || nestedPipefail, + depth+1, + ) + if masked { + return reason, true + } + } + + if !requiredGateArgv(argv) { + return "", false + } + + reason := maskedGateOperatorReason(parsed.operators[index:], pipefail) + if reason != "" { + return reason, true + } + + if sequenceAfter(index, parsed.operators) && + !parsed.returnsStatusFrom(index) { + return gateSequenceReason, true + } + + return "", false +} + +func maskedGateOperatorReason(operators []string, pipefail bool) string { + for _, operator := range operators { + switch operator { + case "||": + return gateFallbackReason + case "&": + return gateBackgroundReason + case "|", "|&": + if !pipefail { + return gatePipelineReason + } + } + } + + return "" +} + +func parseGateShell(command string) (gateShell, error) { + fields, err := shellparse.ControlFields(command) + if err != nil { + return gateShell{}, fmt.Errorf("parse shell control fields: %w", err) + } + + parsed := gateShell{} + segment := []string{} + + for _, field := range fields { + if isShellControlToken(field) { + if len(segment) > 0 { + parsed.segments = append(parsed.segments, segment) + segment = nil + } + + parsed.operators = append(parsed.operators, field) + + continue + } + + segment = append(segment, field) + } + + if len(segment) > 0 { + parsed.segments = append(parsed.segments, segment) + } + + validTrailingBackground := len(parsed.operators) == len(parsed.segments) && + len(parsed.operators) > 0 && + parsed.operators[len(parsed.operators)-1] == "&" + + if len(parsed.segments) == 0 || + (len(parsed.operators)+1 != len(parsed.segments) && + !validTrailingBackground) { + return gateShell{}, nil + } + + return parsed, nil +} + +func (parsed gateShell) enablesPipefail() bool { + for _, segment := range parsed.segments { + if isPipefailCommand(gateExecutableArgv(segment)) { + return true + } + } + + return false +} + +func isPipefailCommand(argv []string) bool { + return len(argv) >= 3 && argv[0] == "set" && + argv[1] == "-o" && argv[2] == "pipefail" +} + +func sequenceAfter(gateIndex int, operators []string) bool { + return slices.Contains(operators[gateIndex:], ";") +} + +func (parsed gateShell) returnsStatusFrom(gateIndex int) bool { + relativeIndex := slices.Index(parsed.operators[gateIndex:], ";") + if relativeIndex < 0 { + return true + } + + sequenceIndex := gateIndex + relativeIndex + commandIndex := sequenceIndex + 1 + + if commandIndex >= len(parsed.segments) { + return true + } + + first := parsed.segments[commandIndex] + if directStatusExit(first, commandIndex, len(parsed.segments)) { + return true + } + + statusName, captured := capturedStatusName(first) + if !captured { + return false + } + + last := gateExecutableArgv(parsed.segments[len(parsed.segments)-1]) + if len(last) < 2 || last[0] != "exit" { + return false + } + + return last[1] == "$"+statusName || + last[1] == "${"+statusName+"}" +} + +func directStatusExit(first []string, commandIndex, segmentCount int) bool { + return len(first) >= 2 && first[0] == "exit" && first[1] == "$?" && + commandIndex == segmentCount-1 +} + +func capturedStatusName(first []string) (string, bool) { + if len(first) == 0 { + return "", false + } + + name, value, found := strings.Cut(first[0], "=") + + return name, found && name != "" && value == "$?" +} + +func gateExecutableArgv(segment []string) []string { + argv := gateExecutableFields(segment) + + for len(argv) > 0 { + unwrapped, changed := unwrapGateWrapper(argv) + if !changed { + return argv + } + + argv = unwrapped + } + + return argv +} + +func gateExecutableFields(segment []string) []string { + argv := make([]string, 0, len(segment)) + + for _, field := range segment { + if shellRedirectField(field) || + (len(argv) == 0 && shellAssignment(field)) { + continue + } + + argv = append(argv, field) + } + + return argv +} + +func unwrapGateWrapper(argv []string) ([]string, bool) { + switch filepath.Base(argv[0]) { + case tokenEnv: + return unwrapEnvArgv(argv[1:]), true + case tokenCommand, tokenExec, "nohup", "time": + return unwrapSimpleArgv(argv[1:]), true + case "nice": + return unwrapNiceArgv(argv[1:]), true + case "timeout": + return unwrapTimeoutArgv(argv[1:]), true + case "flock": + return unwrapFlockArgv(argv[1:]), true + case "cerun": + separator := slices.Index(argv, "--") + if separator < 0 { + return argv, false + } + + return argv[separator+1:], true + default: + return argv, false + } +} + +func shellRedirectField(field string) bool { + trimmed := strings.TrimLeft(field, "0123456789") + + return strings.HasPrefix(trimmed, ">") || + strings.HasPrefix(trimmed, "<") +} + +func unwrapEnvArgv(argv []string) []string { + for len(argv) > 0 { + if shellAssignment(argv[0]) { + argv = argv[1:] + + continue + } + + if envOptionConsumesValue(argv[0]) && len(argv) > 1 { + argv = argv[2:] + + continue + } + + if strings.HasPrefix(argv[0], "-") { + argv = argv[1:] + + continue + } + + break + } + + return argv +} + +func envOptionConsumesValue(argument string) bool { + return slices.Contains( + []string{ + "-u", "--unset", "-C", "--chdir", "-S", "--split-string", + }, + argument, + ) +} + +func unwrapSimpleArgv(argv []string) []string { + for len(argv) > 0 && strings.HasPrefix(argv[0], "-") { + argv = argv[1:] + } + + return argv +} + +func unwrapNiceArgv(argv []string) []string { + for len(argv) > 0 { + if slices.Contains( + []string{"-n", "--adjustment"}, + argv[0], + ) && len(argv) > 1 { + argv = argv[2:] + + continue + } + + if strings.HasPrefix(argv[0], "-") { + argv = argv[1:] + + continue + } + + break + } + + return argv +} + +func unwrapTimeoutArgv(argv []string) []string { + for len(argv) > 0 { + if timeoutOptionConsumesValue(argv[0]) && len(argv) > 1 { + argv = argv[2:] + + continue + } + + if strings.HasPrefix(argv[0], "-") { + argv = argv[1:] + + continue + } + + break + } + + return discardWrapperOperand(argv) +} + +func timeoutOptionConsumesValue(argument string) bool { + return slices.Contains( + []string{"-k", "--kill-after", "-s", "--signal"}, + argument, + ) +} + +func unwrapFlockArgv(argv []string) []string { + for len(argv) > 0 { + if flockOptionConsumesValue(argv[0]) && len(argv) > 1 { + argv = argv[2:] + + continue + } + + if strings.HasPrefix(argv[0], "-") { + argv = argv[1:] + + continue + } + + break + } + + return discardWrapperOperand(argv) +} + +func flockOptionConsumesValue(argument string) bool { + return slices.Contains( + []string{"-E", "--conflict-exit-code", "-w", "--wait"}, + argument, + ) +} + +func discardWrapperOperand(argv []string) []string { + if len(argv) == 0 { + return argv + } + + return argv[1:] +} + +func nestedShellScript(argv []string) (string, bool, bool) { + if len(argv) == 0 || !shellExecutable(argv[0]) { + return "", false, false + } + + pipefail := false + + for index := 1; index < len(argv); index++ { + if argv[index] == "-o" && index+1 < len(argv) && + argv[index+1] == "pipefail" { + pipefail = true + index++ + + continue + } + + if strings.HasPrefix(argv[index], "-") && + strings.Contains(argv[index], "c") && index+1 < len(argv) { + return argv[index+1], pipefail, true + } + } + + return "", pipefail, false +} + +func shellExecutable(argument string) bool { + return slices.Contains( + []string{"bash", "dash", "sh"}, + filepath.Base(argument), + ) +} + +func requiredGateArgv(argv []string) bool { + if len(argv) == 0 { + return false + } + + name := filepath.Base(argv[0]) + arguments := argv[1:] + + if name == "make" { + return slices.ContainsFunc(arguments, requiredMakeGateTarget) + } + + return requiredExecutableGate(name, arguments) +} + +func requiredExecutableGate(name string, arguments []string) bool { + switch name { + case "cargo": + return requiredCargoGate(arguments) + case "go": + return requiredGoGate(arguments) + case pythonExecutable, "python3": + return len(arguments) > 1 && arguments[0] == "-m" && + arguments[1] == pytestExecutable + case pytestExecutable, "ghprsq": + return true + case preCommitExecutable: + return commandOperation( + arguments, + map[string]bool{"--color": true}, + ) == "run" + case "uv": + return uvRunsPytest(arguments) + case tokenGit: + return gitArgvOperation(arguments) == gitCommitOperation + default: + return false + } +} + +func requiredMakeGateTarget(argument string) bool { + switch argument { + case "acceptance", "bench", "build", "check", "check-sync", + gitCommitOperation, + "go-e2e-test", "go-test", "heavy", "install", "lint", + "maint-rust-heavy", "mutants", "nextest", preCommitExecutable, + "purrdf-extractor-check", "reason", "reason-verify", "release", + "rust-coverage", "rust-gate", "rust-test", testOperation, "validate", + "wasm-parity": + return true + default: + return false + } +} + +func requiredGoGate(argv []string) bool { + return commandOperation(argv, map[string]bool{"-C": true}) == + testOperation +} + +func requiredCargoGate(argv []string) bool { + operation := commandOperation(argv, map[string]bool{ + "--color": true, + "--config": true, + "--manifest-path": true, + "--target-dir": true, + }) + + return slices.Contains( + []string{ + "bench", "build", "check", "clippy", "nextest", testOperation, + }, + operation, + ) +} + +func commandOperation( + argv []string, + optionsWithValues map[string]bool, +) string { + for index := 0; index < len(argv); index++ { + argument := argv[index] + name, _, hasInlineValue := strings.Cut(argument, "=") + + if optionsWithValues[name] && !hasInlineValue { + index++ + + continue + } + + if strings.HasPrefix(argument, "+") || + strings.HasPrefix(argument, "-") { + continue + } + + return argument + } + + return "" +} + +func uvRunsPytest(argv []string) bool { + operation := commandOperation(argv, map[string]bool{ + "--config-file": true, + "--directory": true, + "--project": true, + }) + if operation != "run" { + return false + } + + runIndex := slices.Index(argv, "run") + if runIndex < 0 { + return false + } + + operation = commandOperation(argv[runIndex+1:], map[string]bool{ + "--directory": true, + "--env-file": true, + "--index": true, + "--index-url": true, + "--python": true, + }) + + return filepath.Base(operation) == pytestExecutable +} + +func gitArgvOperation(argv []string) string { + for index := 0; index < len(argv); index++ { + argument := argv[index] + + if gitGlobalOptionConsumesValue(argument) { + index++ + + continue + } + + if strings.HasPrefix(argument, "-") { + continue + } + + return argument + } + + return "" +} + +func gitGlobalOptionConsumesValue(argument string) bool { + return slices.Contains( + []string{ + "-c", "-C", "--git-dir", "--work-tree", "--namespace", + "--config-env", + }, + argument, + ) +} + +func shellAssignment(value string) bool { + name, _, found := strings.Cut(value, "=") + if !found || name == "" { + return false + } + + for index, character := range name { + if validShellNameCharacter(index, character) { + continue + } + + return false + } + + return true +} + +func validShellNameCharacter(index int, character rune) bool { + return character == '_' || character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || + index > 0 && character >= '0' && character <= '9' +} diff --git a/go/internal/hooks/gate_exit_status_test.go b/go/internal/hooks/gate_exit_status_test.go new file mode 100644 index 00000000..d96e440b --- /dev/null +++ b/go/internal/hooks/gate_exit_status_test.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package hooks + +import "testing" + +func TestRequiredGateExitStatusBlocksMaskedFailures(t *testing.T) { + t.Parallel() + + for _, command := range []string{ + "make check > gate.log 2>&1; echo EXIT_CODE=$? >> gate.log", + "make check | tail -100", + "make check || true", + "bash -c 'make check; echo done'", + "git -c core.useBuiltinFSMonitor=false commit -m verified | tee commit.log", + "cargo --locked test | tee cargo.log", + "go -C ./go test ./... | tee go.log", + "python3 -m pytest | tee pytest.log", + "env -u CI make check | tee make.log", + "nice make check | tee make.log", + "timeout 30s make check | tee make.log", + "flock /tmp/gate.lock make check | tee make.log", + "make go-test | tee go.log", + "make go-e2e-test | tee go-e2e.log", + "make lint | tee lint.log", + "make purrdf-extractor-check | tee purrdf.log", + } { + route := requiredGateExitStatusRouteFor(Event{ + HookEventName: eventPreToolUse, + ToolName: toolBash, + ToolInput: map[string]any{ + "command": command, + }, + }) + if !route.Block || route.BlockPolicyID != gateExitStatusPolicyID { + t.Fatalf("command %q route = %#v, want required-gate block", command, route) + } + } +} + +func TestRequiredGateExitStatusAllowsAuthoritativeStatus(t *testing.T) { + t.Parallel() + + for _, command := range []string{ + "make check", + "make check && echo gate-passed", + "bash -o pipefail -c 'make check | tail -100'", + "set -o pipefail; make check | tail -100", + "make check > gate.log 2>&1; status=$?; echo EXIT_CODE=$status >> gate.log; exit \"$status\"", + "rg -n 'make check' src", + } { + route := requiredGateExitStatusRouteFor(Event{ + HookEventName: eventPreToolUse, + ToolName: toolBash, + ToolInput: map[string]any{ + "command": command, + }, + }) + if route.Block { + t.Fatalf("authoritative command %q was blocked: %#v", command, route) + } + } +} diff --git a/go/internal/hooks/git_wrapper_enforcement.go b/go/internal/hooks/git_wrapper_enforcement.go index 229a0cb1..b52512cc 100644 --- a/go/internal/hooks/git_wrapper_enforcement.go +++ b/go/internal/hooks/git_wrapper_enforcement.go @@ -22,6 +22,10 @@ const ( tokenGit = "git" tokenCommand = "command" tokenEnv = "env" + tokenExec = "exec" + gitCommitOperation = "commit" + pythonExecutable = "python" + testOperation = "test" wrappedToolArgs = 2 cerunRunnerName = "cerun" wrapperRunnerName = "coding-ethos-run" @@ -494,7 +498,7 @@ func cerunAgentShellSegment(segment []string) bool { } switch args[0] { - case "git", "python", "lint": + case tokenGit, pythonExecutable, "lint": return len(args) > 1 case "--", "--rewrite", "--no-rewrite", "--check": return agentShellArgsHaveCommand(args) @@ -823,7 +827,7 @@ func shellCommandIsEvasiveGitInvocation(parsed shellparse.Command) bool { switch name { case shellToolName, "sh", "zsh", "dash": return shellExecArgumentReferencesGit(parsed) - case tokenCommand, tokenEnv, "eval", "alias", "exec": + case tokenCommand, tokenEnv, "eval", "alias", tokenExec: return true default: return pythonGitBypassCommand(name, parsed) @@ -885,7 +889,7 @@ func shellCommandUsesPathOverride(command shellparse.Command) bool { } } - if shellCommandName(command) == "env" { + if shellCommandName(command) == tokenEnv { for _, arg := range command.Argv[1:] { if strings.HasPrefix(arg, "PATH=") { return true diff --git a/go/internal/hooks/lint_tool_capture.go b/go/internal/hooks/lint_tool_capture.go index 589ebae3..98c080fc 100644 --- a/go/internal/hooks/lint_tool_capture.go +++ b/go/internal/hooks/lint_tool_capture.go @@ -237,7 +237,7 @@ func commandLintToolArgs(segment []string) (toolcatalog.CapturedTool, []string, } func envLintToolArgs(segment []string) (toolcatalog.CapturedTool, []string, bool) { - if filepath.Base(segment[0]) != "env" { + if filepath.Base(segment[0]) != tokenEnv { return toolcatalog.CapturedTool{}, nil, false } @@ -382,7 +382,8 @@ func firstMentionedCapturedTool(command string) toolcatalog.CapturedTool { func isPythonCommand(token string) bool { base := filepath.Base(token) - return base == "python" || base == "python3" || strings.HasPrefix(base, "python3.") + return base == pythonExecutable || base == "python3" || + strings.HasPrefix(base, "python3.") } func segmentMentionsUnmanagedLintTool(segment []string) toolcatalog.CapturedTool { @@ -416,7 +417,7 @@ func shellCommandUsesLintToolIndirection(parsed shellparse.Command) bool { switch shellCommandName(parsed) { case "bash", "sh", "zsh", "dash": return shellExecArgumentMentionsCapturedTool(parsed) - case "eval", "alias", "exec": + case "eval", "alias", tokenExec: return shellCommandArgMentionsCapturedTool(parsed) default: return shellPythonCommandMentionsCapturedTool(parsed) diff --git a/go/internal/hooks/normalizer_internal_test.go b/go/internal/hooks/normalizer_internal_test.go new file mode 100644 index 00000000..fb2e5068 --- /dev/null +++ b/go/internal/hooks/normalizer_internal_test.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package hooks + +import ( + "path/filepath" + "testing" +) + +func TestHookOutputNormalizerTreatsGoTempDirAsTemporary(t *testing.T) { + goTempDir := filepath.Join(t.TempDir(), "go-temp") + t.Setenv("GOTMPDIR", goTempDir) + + transcript := filepath.Join(goTempDir, "session", "transcript.jsonl") + got := hookOutputNormalizer("/repo").preserveLines(transcript) + if got != "/session/transcript.jsonl" { + t.Fatalf("normalized Go temp path = %q", got) + } +} diff --git a/go/internal/hooks/proxy_output.go b/go/internal/hooks/proxy_output.go index ff63b621..aeee5b0e 100644 --- a/go/internal/hooks/proxy_output.go +++ b/go/internal/hooks/proxy_output.go @@ -1300,7 +1300,7 @@ func inferGoDiagnosticTool(argv []string) string { } switch strings.TrimSpace(argv[index+1]) { - case "test": + case testOperation: return "go-test" case "vet": return "go-vet" diff --git a/go/internal/hooks/runner.go b/go/internal/hooks/runner.go index 0908f76d..8617000d 100644 --- a/go/internal/hooks/runner.go +++ b/go/internal/hooks/runner.go @@ -157,6 +157,7 @@ func routeToolUse(ctx InspectionContext) InspectionRoute { parallelToolBatchRouteFor, memoryRouteFor, malformedShellRouteFor, + requiredGateExitStatusRouteFor, shellFileToolRouteFor, gitWrapperRouteFor, lintToolRouteFor, @@ -733,6 +734,9 @@ func hookOutputNormalizer(cwd string) hookTextNormalizer { } roots = append(roots, hookTextReplacement{Old: os.TempDir(), New: ""}) + if goTempDir := strings.TrimSpace(os.Getenv("GOTMPDIR")); goTempDir != "" { + roots = append(roots, hookTextReplacement{Old: goTempDir, New: ""}) + } replacements := []hookTextReplacement{} diff --git a/go/internal/hooks/semantic_policy_injection.go b/go/internal/hooks/semantic_policy_injection.go index a1e557ac..9eaadb35 100644 --- a/go/internal/hooks/semantic_policy_injection.go +++ b/go/internal/hooks/semantic_policy_injection.go @@ -176,7 +176,7 @@ func semanticGitMutation(operation string) bool { "checkout", "cherry-pick", "clean", - "commit", + gitCommitOperation, "merge", "mv", "pull", diff --git a/go/internal/policy/bundle.go b/go/internal/policy/bundle.go index 01e47b06..29ac0c53 100644 --- a/go/internal/policy/bundle.go +++ b/go/internal/policy/bundle.go @@ -266,18 +266,19 @@ func examplePrinciples() map[string]Principle { func examplePolicies() map[string]Policy { return map[string]Policy{ - "python.conditional_imports": exampleConditionalImportPolicy(), - "python.functional_idioms": exampleFunctionalIdiomPolicy(), - "git.hook_bypass": exampleHookBypassPolicy(), - "git.history_rewrite_prevention": exampleHistoryRewritePreventionPolicy(), - "git.protected_submodule_update": exampleProtectedSubmoduleUpdatePolicy(), - "git.commit_attribution": exampleCommitAttributionPolicy(), - "git.commit_head_advanced": exampleCommitHeadPolicy(), - "git.edit_evasive_git_execution": exampleEditEvasiveGitExecutionPolicy(), - "filesystem.protected_path": exampleProtectedPathPolicy(), - "proxy.search_replace_edit": ProxySearchReplaceEditPolicy(), - "shell.malformed_command": exampleShellMalformedCommandPolicy(), - "shell.forbidden_strings": exampleShellForbiddenStringsPolicy(), + "python.conditional_imports": exampleConditionalImportPolicy(), + "python.functional_idioms": exampleFunctionalIdiomPolicy(), + "git.hook_bypass": exampleHookBypassPolicy(), + "git.history_rewrite_prevention": exampleHistoryRewritePreventionPolicy(), + "git.protected_submodule_update": exampleProtectedSubmoduleUpdatePolicy(), + "git.commit_attribution": exampleCommitAttributionPolicy(), + "git.commit_head_advanced": exampleCommitHeadPolicy(), + "git.edit_evasive_git_execution": exampleEditEvasiveGitExecutionPolicy(), + "filesystem.protected_path": exampleProtectedPathPolicy(), + "proxy.search_replace_edit": ProxySearchReplaceEditPolicy(), + "shell.malformed_command": exampleShellMalformedCommandPolicy(), + "shell.required_gate_exit_status": exampleShellRequiredGateExitStatusPolicy(), + "shell.forbidden_strings": exampleShellForbiddenStringsPolicy(), } } @@ -1082,6 +1083,32 @@ func exampleShellMalformedCommandPolicy() Policy { } } +func exampleShellRequiredGateExitStatusPolicy() Policy { + return Policy{ + ID: "shell.required_gate_exit_status", + Category: "shell", + Source: SourceRef{ + File: "config.yaml", + Path: "shell.required_gate_exit_status", + }, + PrincipleIDs: []string{ + "validation-at-the-gate", + "evidence-based-engineering-and-decision-quality", + }, + DefaultSeverity: "block", + SupportedModes: []string{"block", "record"}, + Message: "Required repository gates must return their own " + + "exact terminal status.", + Suggestion: "Run the gate directly, enable pipefail for pipelines, or capture " + + "and return the gate's exact status.", + DefenseLayers: hookRouteDefenseLayers(), + AppliesTo: AppliesTo{Tools: []string{"Bash"}}, + Evaluators: []Evaluator{ + {Kind: "external", Name: "shell.required_gate_exit_status"}, + }, + } +} + func exampleLinterDispatch() map[string][]string { return map[string][]string{ "files": {"python.conditional_imports", "python.functional_idioms"}, diff --git a/go/internal/policy/hook_route_policies.go b/go/internal/policy/hook_route_policies.go index cadc06c9..45c5ee5b 100644 --- a/go/internal/policy/hook_route_policies.go +++ b/go/internal/policy/hook_route_policies.go @@ -18,6 +18,14 @@ package policy // CEL engine does not make. Registering these does not move enforcement. func hookRoutePolicies(principles map[string]Principle) map[string]Policy { + policies := coreHookRoutePolicies(principles) + policy := requiredGateExitStatusRoutePolicy(principles) + policies[policy.ID] = policy + + return policies +} + +func coreHookRoutePolicies(principles map[string]Principle) map[string]Policy { return map[string]Policy{ "shell.file_tool_emulation": { ID: "shell.file_tool_emulation", @@ -99,6 +107,36 @@ func hookRoutePolicies(principles map[string]Principle) map[string]Policy { } } +func requiredGateExitStatusRoutePolicy( + principles map[string]Principle, +) Policy { + return Policy{ + ID: "shell.required_gate_exit_status", + Category: "shell", + Source: SourceRef{ + File: "config.yaml", + Path: "shell.required_gate_exit_status", + }, + PrincipleIDs: principleRefs( + principles, + "validation-at-the-gate", + "evidence-based-engineering-and-decision-quality", + ), + DefaultSeverity: "block", + SupportedModes: []string{"block", "record"}, + DefenseLayers: hookRouteDefenseLayers(), + Message: "Required repository gates must return their own exact " + + "terminal status.", + Suggestion: "Run the gate directly. If output must be filtered or " + + "logged, enable pipefail or capture the gate status immediately " + + "and exit with that exact value.", + AppliesTo: AppliesTo{Tools: []string{"Bash"}}, + Evaluators: []Evaluator{ + {Kind: "external", Name: "shell.required_gate_exit_status"}, + }, + } +} + // hookRouteDefenseLayers describes where these blocks actually happen. // // At the hook, before the tool runs, and nowhere else. The generic code layers diff --git a/go/internal/policygitcli/main.go b/go/internal/policygitcli/main.go index dd7e3a77..77af9fae 100644 --- a/go/internal/policygitcli/main.go +++ b/go/internal/policygitcli/main.go @@ -5,7 +5,6 @@ package policygitcli import ( "bytes" - "flag" "fmt" "io" "os" @@ -26,6 +25,18 @@ var ( errAdminApprovedRequired = apperror.StaticError( "admin-start-branch requires --admin-approved", ) + errWrapperBoolValue = apperror.StaticError( + "policy-git boolean option accepts only true or false", + ) + errWrapperOptionEmpty = apperror.StaticError( + "policy-git option requires a non-empty value", + ) + errWrapperOptionUnknown = apperror.StaticError( + "unknown policy-git option", + ) + errWrapperValueRequired = apperror.StaticError( + "policy-git option requires a value", + ) ) func run() error { @@ -33,32 +44,21 @@ func run() error { } func runWithArgs(args []string) error { - flags := flag.NewFlagSet("coding-ethos-git", flag.ExitOnError) - bundlePath := flags.String("bundle", "", "Path to policy-bundle.json") - realGit := flags.String("real-git", "", "Real git executable") - checkOnly := flags.Bool("check-only", false, "Check policy without executing git") - jsonOutput := flags.Bool("json", false, "Emit JSON result") - adminApproved := flags.Bool( - "admin-approved", - false, - "Allow admin-protected coding-ethos commits when process ancestry is approved", - ) - - err := flags.Parse(args) + parsed, err := parsePolicyGitArgs(args) if err != nil { return fmt.Errorf("parse flags: %w", err) } - if *bundlePath == "" { + if parsed.bundlePath == "" { return errBundleRequired } - bundle, err := readValidatedBundle(*bundlePath) + bundle, err := readValidatedBundle(parsed.bundlePath) if err != nil { return err } - argv := flags.Args() + argv := parsed.gitArgv cwd, err := os.Getwd() if err != nil { @@ -66,13 +66,13 @@ func runWithArgs(args []string) error { } if len(argv) > 0 && argv[0] == "admin-start-branch" { - return startAdminBranch(*realGit, cwd, argv[1:], *adminApproved) + return startAdminBranch(parsed.realGit, cwd, argv[1:], parsed.adminApproved) } - restoreRealGit := exposeRealGitForPolicyEvaluation(*realGit) + restoreRealGit := exposeRealGitForPolicyEvaluation(parsed.realGit) defer restoreRealGit() - options, err := gitOptions(argv, cwd, *adminApproved) + options, err := gitOptions(argv, cwd, parsed.adminApproved) if err != nil { return err } @@ -82,7 +82,7 @@ func runWithArgs(args []string) error { return fmt.Errorf("check git policy: %w", err) } - err = maybePrintJSON(*jsonOutput, result) + err = maybePrintJSON(parsed.jsonOutput, result) if err != nil { return err } @@ -93,13 +93,167 @@ func runWithArgs(args []string) error { return gitwrap.ExitCodeError{Code: blockedExitCode} } - if *checkOnly { - printAllowedCheck(*jsonOutput) + if parsed.checkOnly { + printAllowedCheck(parsed.jsonOutput) return nil } - return executeGitWithPostChecks(bundle, *realGit, options, *jsonOutput) + return executeGitWithPostChecks(bundle, parsed.realGit, options, parsed.jsonOutput) +} + +type policyGitArguments struct { + bundlePath string + realGit string + gitArgv []string + checkOnly bool + jsonOutput bool + adminApproved bool +} + +func parsePolicyGitArgs(args []string) (policyGitArguments, error) { + parsed := policyGitArguments{} + + for index := 0; index < len(args); { + argument := args[index] + + if argument == "--" { + parsed.gitArgv = append([]string(nil), args[index+1:]...) + + return parsed, nil + } + + if gitGlobalOptionStartsArgv(argument) || !strings.HasPrefix(argument, "-") { + parsed.gitArgv = append([]string(nil), args[index:]...) + + return parsed, nil + } + + nextIndex, err := parsed.consumeWrapperOption(args, index) + if err != nil { + return policyGitArguments{}, err + } + + index = nextIndex + } + + return parsed, nil +} + +func (parsed *policyGitArguments) consumeWrapperOption( + args []string, + index int, +) (int, error) { + argument := args[index] + name, value, hasValue := strings.Cut(argument, "=") + + switch name { + case "--bundle", "--real-git": + resolved, nextIndex, err := wrapperStringValue( + args, + index, + name, + value, + hasValue, + ) + if err != nil { + return 0, err + } + + parsed.setStringOption(name, resolved) + + return nextIndex, nil + case "--check-only", "--json", "--admin-approved": + boolValue, err := wrapperBoolValue(name, value, hasValue) + if err != nil { + return 0, err + } + + parsed.setBoolOption(name, boolValue) + + return index + 1, nil + default: + return 0, fmt.Errorf("%w %q", errWrapperOptionUnknown, argument) + } +} + +func wrapperStringValue( + args []string, + index int, + name string, + value string, + hasValue bool, +) (string, int, error) { + if hasValue { + if value == "" { + return "", 0, fmt.Errorf("%w: %s", errWrapperOptionEmpty, name) + } + + return value, index + 1, nil + } + + nextIndex := index + 1 + if nextIndex >= len(args) { + return "", 0, fmt.Errorf("%w: %s", errWrapperValueRequired, name) + } + + value = args[nextIndex] + if value == "" { + return "", 0, fmt.Errorf("%w: %s", errWrapperOptionEmpty, name) + } + + return value, nextIndex + 1, nil +} + +func (parsed *policyGitArguments) setStringOption(name, value string) { + if name == "--bundle" { + parsed.bundlePath = value + + return + } + + parsed.realGit = value +} + +func (parsed *policyGitArguments) setBoolOption(name string, value bool) { + switch name { + case "--check-only": + parsed.checkOnly = value + case "--json": + parsed.jsonOutput = value + case "--admin-approved": + parsed.adminApproved = value + } +} + +func wrapperBoolValue(name, value string, hasValue bool) (bool, error) { + if !hasValue || value == "true" { + return true, nil + } + + if value == "false" { + return false, nil + } + + return false, fmt.Errorf("%w: %s", errWrapperBoolValue, name) +} + +func gitGlobalOptionStartsArgv(argument string) bool { + if strings.HasPrefix(argument, "-c=") || strings.HasPrefix(argument, "-C=") { + return true + } + + name, _, _ := strings.Cut(argument, "=") + switch name { + case "-c", "-C", "-p", "-P", "--paginate", "--no-pager", "--no-replace-objects", + "--bare", "--git-dir", "--work-tree", "--namespace", "--super-prefix", + "--exec-path", "--html-path", "--man-path", "--info-path", "--config-env", + "--literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs", "--icase-pathspecs", + "--version", "--help": + return true + default: + return false + } } func exposeRealGitForPolicyEvaluation(path string) func() { diff --git a/go/internal/policygitcli/main_internal_test.go b/go/internal/policygitcli/main_internal_test.go index 201d8ba6..6187d5bf 100644 --- a/go/internal/policygitcli/main_internal_test.go +++ b/go/internal/policygitcli/main_internal_test.go @@ -67,6 +67,66 @@ func TestGitCommitReadsMessageFromStdin(t *testing.T) { } } +func TestParsePolicyGitArgsPreservesGitGlobalOptions(t *testing.T) { + t.Parallel() + + parsed, err := parsePolicyGitArgs([]string{ + "--bundle", "/policy/bundle.json", + "--real-git=/usr/bin/git", + "--check-only", + "-c", "core.useBuiltinFSMonitor=false", + "init", "--template=/tmp/hooks", + }) + if err != nil { + t.Fatalf("parsePolicyGitArgs: %v", err) + } + + if parsed.bundlePath != "/policy/bundle.json" || parsed.realGit != "/usr/bin/git" || + !parsed.checkOnly { + t.Fatalf("wrapper options = %#v", parsed) + } + want := "-c core.useBuiltinFSMonitor=false init --template=/tmp/hooks" + if got := strings.Join(parsed.gitArgv, " "); got != want { + t.Fatalf("git argv = %q, want %q", got, want) + } +} + +func TestParsePolicyGitArgsHonorsExplicitBoundary(t *testing.T) { + t.Parallel() + + parsed, err := parsePolicyGitArgs([]string{ + "--bundle=/policy/bundle.json", + "--json=false", + "--", + "--future-git-global", "value", "status", + }) + if err != nil { + t.Fatalf("parsePolicyGitArgs: %v", err) + } + if parsed.jsonOutput { + t.Fatal("--json=false was not retained") + } + if got := strings.Join( + parsed.gitArgv, + " ", + ); got != "--future-git-global value status" { + t.Fatalf("git argv = %q", got) + } +} + +func TestParsePolicyGitArgsRejectsUnknownWrapperOption(t *testing.T) { + t.Parallel() + + _, err := parsePolicyGitArgs([]string{ + "--bundle=/policy/bundle.json", + "--typo-wrapper-option", + "status", + }) + if err == nil || !strings.Contains(err.Error(), "unknown policy-git option") { + t.Fatalf("error = %v, want unknown wrapper option", err) + } +} + func TestGitOptionsForNonStdinCommand(t *testing.T) { t.Parallel() diff --git a/pre-commit/PRE-COMMIT.md b/pre-commit/PRE-COMMIT.md index 9418e5c6..77396139 100644 --- a/pre-commit/PRE-COMMIT.md +++ b/pre-commit/PRE-COMMIT.md @@ -166,18 +166,20 @@ Gemini `cachedContents` entries when the same batch corpus is reviewed by multiple prompts, and can run `standard`, `flex`, or `priority` requests from merged `config.yaml` plus `repo_config.yaml`. -The hook runtime is built into the checked-out `coding-ethos` repository: +The selected `coding-ethos` authority builds the hook runtime: - `bin/` contains built hook and policy binaries. - `build/policy/` contains the compiled policy bundle and source-hash metadata. -The old `.git/coding-ethos-hooks/` runtime cache is legacy. The current runtime -model is documented in `docs/HOOK_RUNTIME_BOOTSTRAP.md`: installed consumer -hooks are generated runner entrypoint scripts; all repo-discovery, build-repair, -and dispatch -behavior lives in compiled Go while binaries and compiled runtime files are -built and executed from the checked-out `coding-ethos` repository. +The current runtime model is documented in +`docs/HOOK_RUNTIME_BOOTSTRAP.md`: installed consumer hooks are generated runner +entrypoint scripts that dispatch to the stable Git-common +`.git/coding-ethos-hooks/` projection. `parent-install` atomically refreshes and +hash-verifies its compiled Go executables from the selected authority, while +`make build` refreshes the complete policy and toolchain projection. Hook +execution therefore does not depend on the lifetime or visibility of one +worktree checkout. The same wrapper also exposes local policy-runtime entrypoints: diff --git a/pre-commit/hooks/HOOKS.md b/pre-commit/hooks/HOOKS.md index aeb653ea..43113c19 100644 --- a/pre-commit/hooks/HOOKS.md +++ b/pre-commit/hooks/HOOKS.md @@ -5,12 +5,14 @@ Go-backed Git hooks for coding-ethos bundles. -Installed consumer repository shims are intentionally thin. They discover the -consumer repo, locate the checked-out `coding-ethos` bundle, repair missing -checkout-local runtime artifacts with `make -C build`, and -dispatch to binaries under `coding-ethos/bin/`. Policy selection and strict -policy freshness checks stay inside the `coding-ethos` checkout; lifecycle -hooks do not use a consumer `.git/coding-ethos-hooks` runtime cache. +Installed consumer repository shims are intentionally thin. They dispatch to +the compiled runner under the repository's stable Git-common +`.git/coding-ethos-hooks/bin/` projection. The selected `coding-ethos` +authority builds that projection; `parent-install` atomically refreshes and +hash-verifies its Go executables, and `make build` refreshes the complete policy +and toolchain runtime. Policy selection and strict freshness checks remain in +compiled Coding Ethos code rather than in the shim, and no hook points at a +worktree-local build path. The Go runner is the output-control layer for Git hooks. It supports `hooks.output_format` values of `auto`, `human`, `json`, and `toon`; `auto` From 0f2535b5d8e3b81867ad291f33626f7105c4e52e Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Sun, 30 Aug 2026 23:36:53 -0600 Subject: [PATCH 05/30] fix(hooks): keep uv project environments writable --- docs/HOOK_RUNTIME_BOOTSTRAP.md | 3 ++ go/internal/hookrunnercli/external_tool.go | 15 ++++++++- .../external_tool_internal_test.go | 31 ++++++++++++++++--- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/docs/HOOK_RUNTIME_BOOTSTRAP.md b/docs/HOOK_RUNTIME_BOOTSTRAP.md index 282c8a40..5921ceaf 100644 --- a/docs/HOOK_RUNTIME_BOOTSTRAP.md +++ b/docs/HOOK_RUNTIME_BOOTSTRAP.md @@ -219,6 +219,9 @@ Bootstrap needs a few guardrails: - Keep authority build outputs under ignored `bin/` and `build/` directories. - Keep response, trace, and other transient repo-local caches under ignored `.coding-ethos/` paths, not under the Git common runtime. +- Hook-launched `uv` commands bind both their download cache and project + environment to the consumer-owned `.coding-ethos/cache/` tree. The installed + shared runtime remains read-only and never receives a generated `.venv`. - Install common-runtime executables with temporary-file sync plus atomic rename, and verify them by content rather than mtime. - Keep installed hook entrypoints stable and move versioned behavior into the diff --git a/go/internal/hookrunnercli/external_tool.go b/go/internal/hookrunnercli/external_tool.go index 2fecf4d8..ceff1d46 100644 --- a/go/internal/hookrunnercli/external_tool.go +++ b/go/internal/hookrunnercli/external_tool.go @@ -227,6 +227,7 @@ type externalToolCacheEnvironment struct { GoCache string GolangCILintDir string UVCache string + UVProjectEnv string // CargoTarget is per-repository build output, like GoCache. CargoTarget string // CargoHome and RustupHome are the operator's, not the repository's. Cargo @@ -291,9 +292,17 @@ func externalToolCacheEnv(root string) (externalToolCacheEnvironment, error) { goCache := filepath.Join(root, sandbox.SandboxGoCachePath) golangCILintDir := filepath.Join(root, sandbox.SandboxGolangCIPath) uvCache := filepath.Join(root, ".coding-ethos", "cache", "uv") + uvProjectEnv := filepath.Join(root, ".coding-ethos", "cache", "uv-project-env") cargoTarget := filepath.Join(root, ".coding-ethos", "cache", "cargo-target") - for _, dir := range []string{goTemp, goCache, golangCILintDir, uvCache, cargoTarget} { + for _, dir := range []string{ + goTemp, + goCache, + golangCILintDir, + uvCache, + uvProjectEnv, + cargoTarget, + } { err := os.MkdirAll(dir, externalToolCacheDirMode) if err != nil { return externalToolCacheEnvironment{}, fmt.Errorf( @@ -311,6 +320,7 @@ func externalToolCacheEnv(root string) (externalToolCacheEnvironment, error) { GoCache: goCache, GolangCILintDir: golangCILintDir, UVCache: uvCache, + UVProjectEnv: uvProjectEnv, CargoTarget: cargoTarget, CargoHome: cargoHome, RustupHome: rustupHome, @@ -383,6 +393,7 @@ func (environment externalToolCacheEnvironment) items() []string { "GOCACHE", "GOLANGCI_LINT_CACHE", "UV_CACHE_DIR", + "UV_PROJECT_ENVIRONMENT", "CARGO_TARGET_DIR", "CARGO_HOME", "RUSTUP_HOME", @@ -408,6 +419,8 @@ func (environment externalToolCacheEnvironment) value(name string) string { return environment.GolangCILintDir case "UV_CACHE_DIR": return environment.UVCache + case "UV_PROJECT_ENVIRONMENT": + return environment.UVProjectEnv case "CARGO_TARGET_DIR": return environment.CargoTarget case "CARGO_HOME": diff --git a/go/internal/hookrunnercli/external_tool_internal_test.go b/go/internal/hookrunnercli/external_tool_internal_test.go index 02380622..49206925 100644 --- a/go/internal/hookrunnercli/external_tool_internal_test.go +++ b/go/internal/hookrunnercli/external_tool_internal_test.go @@ -14,28 +14,42 @@ import ( "blackcat.ca/coding-ethos/go/internal/testlock" ) -func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVCache(t *testing.T) { +func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVEnvironment(t *testing.T) { testlock.ProcessState(t, "hook-process-cache-environment") root := t.TempDir() t.Setenv("UV_CACHE_DIR", "/previous/uv-cache") + t.Setenv("UV_PROJECT_ENVIRONMENT", "/previous/uv-project-environment") restore, err := prepareHookProcessCacheEnvironment(root) if err != nil { t.Fatalf("prepareHookProcessCacheEnvironment: %v", err) } - want := filepath.Join(root, ".coding-ethos", "cache", "uv") - if got := os.Getenv("UV_CACHE_DIR"); got != want { - t.Fatalf("UV_CACHE_DIR = %q, want %q", got, want) + wantCache := filepath.Join(root, ".coding-ethos", "cache", "uv") + if got := os.Getenv("UV_CACHE_DIR"); got != wantCache { + t.Fatalf("UV_CACHE_DIR = %q, want %q", got, wantCache) } - if info, statErr := os.Stat(want); statErr != nil || !info.IsDir() { + if info, statErr := os.Stat(wantCache); statErr != nil || !info.IsDir() { t.Fatalf("UV cache directory is not usable: info=%v error=%v", info, statErr) } + wantProjectEnv := filepath.Join(root, ".coding-ethos", "cache", "uv-project-env") + if got := os.Getenv("UV_PROJECT_ENVIRONMENT"); got != wantProjectEnv { + t.Fatalf("UV_PROJECT_ENVIRONMENT = %q, want %q", got, wantProjectEnv) + } + if info, statErr := os.Stat(wantProjectEnv); statErr != nil || !info.IsDir() { + t.Fatalf("UV project environment is not usable: info=%v error=%v", info, statErr) + } + restore() if got := os.Getenv("UV_CACHE_DIR"); got != "/previous/uv-cache" { t.Fatalf("restored UV_CACHE_DIR = %q", got) } + if got := os.Getenv( + "UV_PROJECT_ENVIRONMENT", + ); got != "/previous/uv-project-environment" { + t.Fatalf("restored UV_PROJECT_ENVIRONMENT = %q", got) + } } func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { @@ -131,6 +145,13 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Fatalf("externalToolEnv did not set uv cache dir: %#v", env) } + if !slices.Contains( + env, + "UV_PROJECT_ENVIRONMENT="+filepath.Join(repo, ".coding-ethos/cache/uv-project-env"), + ) { + t.Fatalf("externalToolEnv did not set uv project environment: %#v", env) + } + for _, item := range env { if !strings.HasPrefix(item, "PATH=") { continue From 900d3df121e64d90cd6f7b3de5ab6e563d96f32a Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 00:25:44 -0600 Subject: [PATCH 06/30] fix(hooks): seal uv project dependencies --- .gitignore | 1 + docs/HOOK_RUNTIME_BOOTSTRAP.md | 6 +- go/internal/hookrunnercli/external_tool.go | 5 + .../external_tool_internal_test.go | 12 + pre-commit/hooks/uv.lock | 1459 +++++++++++++++++ 5 files changed, 1481 insertions(+), 2 deletions(-) create mode 100644 pre-commit/hooks/uv.lock diff --git a/.gitignore b/.gitignore index 71e90a56..b7753e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ ehthumbs.db *.swp *.swo *.lock +!pre-commit/hooks/uv.lock # Generated repo-root markdown and tool config files are intentionally versioned. # Do not ignore: diff --git a/docs/HOOK_RUNTIME_BOOTSTRAP.md b/docs/HOOK_RUNTIME_BOOTSTRAP.md index 5921ceaf..a1460776 100644 --- a/docs/HOOK_RUNTIME_BOOTSTRAP.md +++ b/docs/HOOK_RUNTIME_BOOTSTRAP.md @@ -220,8 +220,10 @@ Bootstrap needs a few guardrails: - Keep response, trace, and other transient repo-local caches under ignored `.coding-ethos/` paths, not under the Git common runtime. - Hook-launched `uv` commands bind both their download cache and project - environment to the consumer-owned `.coding-ethos/cache/` tree. The installed - shared runtime remains read-only and never receives a generated `.venv`. + environment to the consumer-owned `.coding-ethos/cache/` tree and use the + sealed project's committed lockfile in frozen mode. The installed shared + runtime remains read-only and never receives a generated `.venv` or a lockfile + rewrite. - Install common-runtime executables with temporary-file sync plus atomic rename, and verify them by content rather than mtime. - Keep installed hook entrypoints stable and move versioned behavior into the diff --git a/go/internal/hookrunnercli/external_tool.go b/go/internal/hookrunnercli/external_tool.go index ceff1d46..1588306f 100644 --- a/go/internal/hookrunnercli/external_tool.go +++ b/go/internal/hookrunnercli/external_tool.go @@ -228,6 +228,7 @@ type externalToolCacheEnvironment struct { GolangCILintDir string UVCache string UVProjectEnv string + UVFrozen string // CargoTarget is per-repository build output, like GoCache. CargoTarget string // CargoHome and RustupHome are the operator's, not the repository's. Cargo @@ -321,6 +322,7 @@ func externalToolCacheEnv(root string) (externalToolCacheEnvironment, error) { GolangCILintDir: golangCILintDir, UVCache: uvCache, UVProjectEnv: uvProjectEnv, + UVFrozen: "1", CargoTarget: cargoTarget, CargoHome: cargoHome, RustupHome: rustupHome, @@ -394,6 +396,7 @@ func (environment externalToolCacheEnvironment) items() []string { "GOLANGCI_LINT_CACHE", "UV_CACHE_DIR", "UV_PROJECT_ENVIRONMENT", + "UV_FROZEN", "CARGO_TARGET_DIR", "CARGO_HOME", "RUSTUP_HOME", @@ -421,6 +424,8 @@ func (environment externalToolCacheEnvironment) value(name string) string { return environment.UVCache case "UV_PROJECT_ENVIRONMENT": return environment.UVProjectEnv + case "UV_FROZEN": + return environment.UVFrozen case "CARGO_TARGET_DIR": return environment.CargoTarget case "CARGO_HOME": diff --git a/go/internal/hookrunnercli/external_tool_internal_test.go b/go/internal/hookrunnercli/external_tool_internal_test.go index 49206925..6d1a01eb 100644 --- a/go/internal/hookrunnercli/external_tool_internal_test.go +++ b/go/internal/hookrunnercli/external_tool_internal_test.go @@ -20,6 +20,7 @@ func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVEnvironment(t *testi root := t.TempDir() t.Setenv("UV_CACHE_DIR", "/previous/uv-cache") t.Setenv("UV_PROJECT_ENVIRONMENT", "/previous/uv-project-environment") + t.Setenv("UV_FROZEN", "0") restore, err := prepareHookProcessCacheEnvironment(root) if err != nil { t.Fatalf("prepareHookProcessCacheEnvironment: %v", err) @@ -40,6 +41,9 @@ func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVEnvironment(t *testi if info, statErr := os.Stat(wantProjectEnv); statErr != nil || !info.IsDir() { t.Fatalf("UV project environment is not usable: info=%v error=%v", info, statErr) } + if got := os.Getenv("UV_FROZEN"); got != "1" { + t.Fatalf("UV_FROZEN = %q, want %q", got, "1") + } restore() if got := os.Getenv("UV_CACHE_DIR"); got != "/previous/uv-cache" { @@ -50,6 +54,9 @@ func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVEnvironment(t *testi ); got != "/previous/uv-project-environment" { t.Fatalf("restored UV_PROJECT_ENVIRONMENT = %q", got) } + if got := os.Getenv("UV_FROZEN"); got != "0" { + t.Fatalf("restored UV_FROZEN = %q", got) + } } func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { @@ -74,6 +81,7 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Setenv("GIT_CONFIG_VALUE_0", "test@example.com") t.Setenv("CODING_ETHOS_REAL_GIT", "/tmp/hook-real-git") t.Setenv("GOCACHE", "/tmp/host-go-cache") + t.Setenv("UV_FROZEN", "0") t.Setenv("PATH", shimDir+string(os.PathListSeparator)+"/usr/bin") t.Setenv(consumerRootEnv, repo) t.Setenv(hookGroupChildEnv, hookPlanBoolTrue) @@ -152,6 +160,10 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Fatalf("externalToolEnv did not set uv project environment: %#v", env) } + if !slices.Contains(env, "UV_FROZEN=1") || slices.Contains(env, "UV_FROZEN=0") { + t.Fatalf("externalToolEnv did not freeze the sealed uv project: %#v", env) + } + for _, item := range env { if !strings.HasPrefix(item, "PATH=") { continue diff --git a/pre-commit/hooks/uv.lock b/pre-commit/hooks/uv.lock new file mode 100644 index 00000000..a75e95d2 --- /dev/null +++ b/pre-commit/hooks/uv.lock @@ -0,0 +1,1459 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[options.exclude-newer-package] +pip = "2026-04-26T21:00:06Z" +sqlfluff = "2026-05-15T00:00:00Z" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, + { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, + { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, + { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, + { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, + { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, + { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, + { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[package.optional-dependencies] +sarif = [ + { name = "jschema-to-python" }, + { name = "sarif-om" }, +] + +[[package]] +name = "black" +version = "26.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/58/0a9d9b1195c159d206000c541c3e05897e339be754f0e4d8b29445ab536e/black-26.5.0.tar.gz", hash = "sha256:5cbe4cc4037ffca34cdb0a6a9a046f104b262d0bd63c30fd4a88c7adc2049b1d", size = 677762, upload-time = "2026-05-16T17:57:12.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/0e/328992a8ce73c93605e7fe7325bcf38d3f1bc9b0118b514873699a5ed379/black-26.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2b64ce9841e8b8254c3d702ebccdaf5c520607df8aa4176f5732b7f9af1e6f6", size = 2003830, upload-time = "2026-05-16T18:01:12.853Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/0ded3f1c10306c0d4c5b112ec7c75bd323a199b96d9a0c61f4116ab985e8/black-26.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0a789a41b386f0f83711785f182f2977138ba9cc1f41ad0f6fbc8faac4d2639e", size = 1810249, upload-time = "2026-05-16T18:01:14.367Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/b5cf00e7d8e5b168bfc389e3b937b8d1250cfdda0c6c607f91dba0d5c2a7/black-26.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f69837f7e26d67b1d1e9d0ed49231a14a0469f266e44cd142873e0552f325395", size = 1879117, upload-time = "2026-05-16T18:01:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/b5/0e/01baec29dd65ecca6be69d721b90dfff473b0e49fb49bb1b5b3fa470ab9d/black-26.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:c5b08371561dae9c90391fe7f2138fe7fa495437d3bb134eb865839036e65784", size = 1486102, upload-time = "2026-05-16T18:01:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/36/4b/6f9623c8cd5a3c6883318800e2073761fd9db1e859f594ee42e95c18fcd6/black-26.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:3968ce82ca0bd4914769518490d91a9b0ef2ff2fc68e2122d22b5915a0342eaa", size = 1286888, upload-time = "2026-05-16T18:01:19.275Z" }, + { url = "https://files.pythonhosted.org/packages/75/d1/40d151b65b659848001ec8b8226323a6f25ee535a2f9d441392e1d86933b/black-26.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea8a0c4505486c132c6640e4e108d25f41360a06d844db5a76477c3dbae1b616", size = 1998941, upload-time = "2026-05-16T18:01:20.788Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/991d741faf172502f17966ad8abb7e5b6ce06560855938000564dcf8e1f1/black-26.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2178a70e7c45fb85999b687d8326abceef1e7227463d5d7e07ef125c9fbb9c5c", size = 1810853, upload-time = "2026-05-16T18:01:22.369Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6c/6bb8ab3fa60074d5295162493482b4ed01c33dd19acf1754497fd506caed/black-26.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3ad14d7c24c40eafecf4fb212d9c01e7c7b2ab05c8646b351c93728f499c555", size = 1874114, upload-time = "2026-05-16T18:01:23.973Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/d9dc4206bbd9313d5c3761bd88e9bece5c85e909e5870c46bb7f835ecbcb/black-26.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:8ea767bae9c4f331ea9ad2e08895c951e600dffd550a42624d5210a908720b39", size = 1508463, upload-time = "2026-05-16T18:01:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/2c5fc4152fc3bf79aa498bce429581b87aca340da2fde92423c0b6ce74bd/black-26.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:d658f4ee6167797b08be07ee4bbf6045753ddabfc676c3cb0eec23752ca83eff", size = 1312669, upload-time = "2026-05-16T18:01:27.503Z" }, + { url = "https://files.pythonhosted.org/packages/14/c8/13da5c6a37b46a690199e0895c33a758ba4f2ec3cd81d1d72ebb373509a8/black-26.5.0-py3-none-any.whl", hash = "sha256:241f25bf59f5ca17f5121031e310e089b84cd22bb4eca47360099ea825544f17", size = 212907, upload-time = "2026-05-16T17:57:10.792Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "coding-ethos-hooks" +version = "0.3.0" +source = { editable = "." } +dependencies = [ + { name = "bandit", extra = ["sarif"] }, + { name = "black" }, + { name = "mypy" }, + { name = "mypy-extensions" }, + { name = "pandas-stubs" }, + { name = "pip" }, + { name = "pydantic" }, + { name = "pylint" }, + { name = "pyright" }, + { name = "pyupgrade" }, + { name = "pyyaml" }, + { name = "radon" }, + { name = "ruff" }, + { name = "sqlfluff" }, + { name = "tombi" }, + { name = "types-aiofiles" }, + { name = "types-beautifulsoup4" }, + { name = "types-decorator" }, + { name = "types-docker" }, + { name = "types-openpyxl" }, + { name = "types-pexpect" }, + { name = "types-pillow" }, + { name = "types-psutil" }, + { name = "types-psycopg2" }, + { name = "types-pyasn1" }, + { name = "types-pycurl" }, + { name = "types-pygments" }, + { name = "types-pymysql" }, + { name = "types-tqdm" }, + { name = "types-ujson" }, + { name = "types-xlrd" }, + { name = "uv" }, + { name = "vulture" }, + { name = "yamllint" }, +] + +[package.metadata] +requires-dist = [ + { name = "bandit", extras = ["sarif", "toml"], specifier = ">=1.8.6" }, + { name = "black", specifier = ">=26.1.0" }, + { name = "mypy", specifier = ">=1.19.1" }, + { name = "mypy-extensions", specifier = ">=1.1.0" }, + { name = "pandas-stubs", specifier = ">=3.0.0.260204" }, + { name = "pip", specifier = ">=26.1" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pylint", specifier = ">=4.0.4" }, + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pyupgrade", specifier = ">=3.21.2" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "radon", specifier = ">=6.0.1" }, + { name = "ruff", specifier = ">=0.15.1" }, + { name = "sqlfluff", specifier = ">=4.2.0" }, + { name = "tombi", specifier = ">=0.6.12" }, + { name = "types-aiofiles", specifier = ">=25.1.0.20260508" }, + { name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" }, + { name = "types-decorator", specifier = ">=5.2.0.20260508" }, + { name = "types-docker", specifier = ">=7.1.0.20260409" }, + { name = "types-openpyxl", specifier = ">=3.1.5.20260508" }, + { name = "types-pexpect", specifier = ">=4.9.0.20260508" }, + { name = "types-pillow", specifier = ">=10.2.0.20240822" }, + { name = "types-psutil", specifier = ">=7.2.2.20260408" }, + { name = "types-psycopg2", specifier = ">=2.9.21.20260422" }, + { name = "types-pyasn1", specifier = ">=0.6.0.20260408" }, + { name = "types-pycurl", specifier = ">=7.45.7.20260408" }, + { name = "types-pygments", specifier = ">=2.20.0.20260508" }, + { name = "types-pymysql", specifier = ">=1.1.0.20260508" }, + { name = "types-tqdm", specifier = ">=4.67.3.20260508" }, + { name = "types-ujson", specifier = ">=5.10.0.20250822" }, + { name = "types-xlrd", specifier = ">=2.0.0.20260408" }, + { name = "uv", specifier = ">=0.10.4" }, + { name = "vulture", specifier = ">=2.14" }, + { name = "yamllint", specifier = ">=1.38.0" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +] + +[[package]] +name = "diff-cover" +version = "10.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "jinja2" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/b4/eee71d1e338bc1f9bd3539b46b70e303dac061324b759c9a80fa3c96d90d/diff_cover-10.2.0.tar.gz", hash = "sha256:61bf83025f10510c76ef6a5820680cf61b9b974e8f81de70c57ac926fa63872a", size = 102473, upload-time = "2026-01-09T01:59:07.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2c/61eeb887055a37150db824b6bf830e821a736580769ac2fea4eadb0d613f/diff_cover-10.2.0-py3-none-any.whl", hash = "sha256:59c328595e0b8948617cc5269af9e484c86462e2844bfcafa3fb37f8fca0af87", size = 56748, upload-time = "2026-01-09T01:59:06.028Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jschema-to-python" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonpickle" }, + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/7f/5ae3d97ddd86ec33323231d68453afd504041efcfd4f4dde993196606849/jschema_to_python-1.2.3.tar.gz", hash = "sha256:76ff14fe5d304708ccad1284e4b11f96a658949a31ee7faed9e0995279549b91", size = 10061, upload-time = "2019-10-05T20:02:39.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/9e/1b6819a87c3f59170406163ba17bc55b0abe18ae552f53d2b0a2025f9c63/jschema_to_python-1.2.3-py3-none-any.whl", hash = "sha256:8a703ca7604d42d74b2815eecf99a33359a8dccbb80806cce386d5e2dd992b05", size = 10400, upload-time = "2019-10-05T20:02:37.948Z" }, +] + +[[package]] +name = "jsonpickle" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/a6/d07afcfdef402900229bcca795f80506b207af13a838d4d99ad45abf530c/jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1", size = 316885, upload-time = "2025-06-02T20:36:11.57Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas-stubs" +version = "3.0.0.260204" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/1d/297ff2c7ea50a768a2247621d6451abb2a07c0e9be7ca6d36ebe371658e5/pandas_stubs-3.0.0.260204.tar.gz", hash = "sha256:bf9294b76352effcffa9cb85edf0bed1339a7ec0c30b8e1ac3d66b4228f1fbc3", size = 109383, upload-time = "2026-02-04T15:17:17.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/2f/f91e4eee21585ff548e83358332d5632ee49f6b2dcd96cb5dca4e0468951/pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241", size = 168540, upload-time = "2026-02-04T15:17:15.615Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pbr" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ab/1de9a4f730edde1bdbbc2b8d19f8fa326f036b4f18b2f72cfbea7dc53c26/pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29", size = 135625, upload-time = "2025-11-03T17:04:56.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, +] + +[[package]] +name = "pip" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyupgrade" +version = "3.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tokenize-rt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/a1/dc63caaeed232b1c58eae1b7a75f262d64ab8435882f696ffa9b58c0c415/pyupgrade-3.21.2.tar.gz", hash = "sha256:1a361bea39deda78d1460f65d9dd548d3a36ff8171d2482298539b9dc11c9c06", size = 45455, upload-time = "2025-11-19T00:39:48.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8c/433dac11910989a90c40b10149d07ef7224232236971a562d3976790ec53/pyupgrade-3.21.2-py2.py3-none-any.whl", hash = "sha256:2ac7b95cbd176475041e4dfe8ef81298bd4654a244f957167bd68af37d52be9f", size = 62814, upload-time = "2025-11-19T00:39:46.958Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, + { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, + { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, + { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "sarif-om" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pbr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/de/bbdd93fe456d4011500784657c5e4a31e3f4fcbb276255d4db1213aed78c/sarif_om-1.0.4.tar.gz", hash = "sha256:cd5f416b3083e00d402a92e449a7ff67af46f11241073eea0461802a3b5aef98", size = 28847, upload-time = "2019-10-05T20:11:23.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/7c/1d3d0467565aa8b3e77ab8712042a09dd1158056826f45783f3d2b34adf1/sarif_om-1.0.4-py3-none-any.whl", hash = "sha256:539ef47a662329b1c8502388ad92457425e95dc0aaaf995fe46f4984c4771911", size = 30193, upload-time = "2019-10-05T20:11:21.577Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlfluff" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, + { name = "click" }, + { name = "colorama" }, + { name = "diff-cover" }, + { name = "jinja2" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "tblib" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/a1/3430aebc4fae35d7e466e793b5da2f36c2245af092311520dc1d3d3146d6/sqlfluff-4.2.1.tar.gz", hash = "sha256:32f43fbf6721e57f1a5a87d71df0d94b84ecba6ed65727266c7fa60991110fb9", size = 1013384, upload-time = "2026-05-14T21:15:37.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/55/8830f3204939cc965c72680bfae99b0f3fd6c16bffdaf79372ad0a3d1ca6/sqlfluff-4.2.1-py3-none-any.whl", hash = "sha256:ea84f196c41f45df40a851b0881cb3fbb660570e07acbbfd304ff4e9b893424d", size = 1002493, upload-time = "2026-05-14T21:15:35.582Z" }, +] + +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "tblib" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8a/14c15ae154895cc131174f858c707790d416c444fc69f93918adfd8c4c0b/tblib-3.2.2.tar.gz", hash = "sha256:e9a652692d91bf4f743d4a15bc174c0b76afc750fe8c7b6d195cc1c1d6d2ccec", size = 35046, upload-time = "2025-11-12T12:21:16.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl", hash = "sha256:26bdccf339bcce6a88b2b5432c988b266ebbe63a4e593f6b578b1d2e723d2b76", size = 12893, upload-time = "2025-11-12T12:21:14.407Z" }, +] + +[[package]] +name = "tokenize-rt" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, +] + +[[package]] +name = "tombi" +version = "0.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/8c/3dad08999de09f07941c473aa3cb80e54a1a3dc6213002421ec18165844a/tombi-0.11.5.tar.gz", hash = "sha256:16f58179b2a96f7e0150979182e0bba55d101762a7a16dc9c7daa3881ae864b1", size = 691093, upload-time = "2026-05-17T01:50:38.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/09/f98759bd844bbff7d7c335b4ef664e2c8f720d73f30dbfd322809a29a367/tombi-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aa2cb09621b36fdcfea06924510992a1ac36e73d5a83a4a0aaa60fef8ee83bbd", size = 10400173, upload-time = "2026-05-17T01:50:24.93Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/22d576daaf919aff631dfc77bba6f5c5333ce8def85d1916954d1b6bffc2/tombi-0.11.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e81c81cdfbd4c584ebfcf1e5ad906a46b40df7d3211d034dec68cfa35337d6d8", size = 10099584, upload-time = "2026-05-17T01:50:22.44Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/e668e0ea87a1fb2523cc22157ad871d319bf7b544d02dd392411bebb01fb/tombi-0.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:33981e93b75f26953dc1c6992e2877ebe0089785b08d9f2261942037cecfcbb7", size = 10408432, upload-time = "2026-05-17T01:50:09.073Z" }, + { url = "https://files.pythonhosted.org/packages/39/05/ca751dc7b86891273e95926ff26cb7bcb82d48a17bf02313dea258321cdc/tombi-0.11.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e73e88682003f6d1a42e7185b57f8030eeb6e919e24918fde5c300323a7638f5", size = 11738101, upload-time = "2026-05-17T01:50:17.32Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/8c214b74c208bdf7ce6bf37b78928993f1817227756f07547badf61ca13b/tombi-0.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0bf49b8aec6d5f7da397613dd63ec9a7bda935147f4ceec3dbd92527758b777", size = 11782947, upload-time = "2026-05-17T01:50:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/68a0dbdc871828a2faeacba69badd1b030a956d1f585b88b4f2dd0debfff/tombi-0.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3f31d4b8e3a902b0251cffd41cf16065e1122a9f93a27fb9776a846360abc7e", size = 10423667, upload-time = "2026-05-17T01:50:14.417Z" }, + { url = "https://files.pythonhosted.org/packages/45/80/340280e3a32b134d0447a866341f9c919c4a4166ed90b48ecfd512e6b3c1/tombi-0.11.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df631bdceb7d58615bebd89464450f501d949f80293f16e02d57c8ce3a05b370", size = 10860129, upload-time = "2026-05-17T01:50:19.975Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/9030fdb90e5a1293a5089576a04955b9c6bc698d63ffc67af996518576d0/tombi-0.11.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5b40294861111906d18a8f52a9ce10a97b7631249f2c2b7d496058f0b5dcb131", size = 10648091, upload-time = "2026-05-17T01:50:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e6/51/cc0e8796c25215100842f520287720dd14f46c9c14c472f62422fbf9cb0f/tombi-0.11.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:daa220f608cb76798f0f1957aff9f036b42a2f1b4c4f6122db1e0e275460a7b7", size = 10673541, upload-time = "2026-05-17T01:50:27.459Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/1489b16322e27edb7788d8eb914278a2dab1528d21ea04321082a6e6b8ca/tombi-0.11.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:078887f8dbdcffcb8083f7348491ae4393dd481dc0f280361457c09bc379f159", size = 10458941, upload-time = "2026-05-17T01:50:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/32/99/fb87b6c2de87d891fbc4c807f71c4b9382ae446a3b3e0de01d2fe0f3a3d4/tombi-0.11.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33e233c4d5e6ab6f5d4c7110b3c11cdd4e7fc909c0fda31c3527e223c6a4e0ad", size = 11135135, upload-time = "2026-05-17T01:50:32.914Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/751b9351cf63a4e4053d4593c89f821ee228e4d911d18ee9e2e30b629306/tombi-0.11.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5397f12c6d68be9e86a224359ffb82cc6700ba2e113f22cce839cc4cf2e965d3", size = 11099695, upload-time = "2026-05-17T01:50:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/7f3c2d4937db3472ac1c24a0a76f9871a278b05e2b38fdf303fa2e7173b0/tombi-0.11.5-py3-none-win32.whl", hash = "sha256:2419c220b543caace7b3561c7e2596ff104472fb88c729aec20d93c772b9ceca", size = 8458415, upload-time = "2026-05-17T01:50:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fd/ae01728e35571a063101559cb3f67a6e206763bb8c99ea3e06d3822c70d0/tombi-0.11.5-py3-none-win_amd64.whl", hash = "sha256:1458c9756a02efe33224a803f69ed16f78f7c01a1e88be1ce18797295851916a", size = 9825141, upload-time = "2026-05-17T01:50:40.639Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "types-aiofiles" +version = "25.1.0.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/d9/60e8b26ad7e57eb3b58f3370b35f0d740dd0909079417e784f4e6c0f92f6/types_aiofiles-25.1.0.20260508.tar.gz", hash = "sha256:d26b07bb28f36c154c77d33982e506ee462044932d42c4eea6e78f69d1de5b84", size = 14851, upload-time = "2026-05-08T04:49:48.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/88/31d8fb1a3d0a96e1e41cfc58c67f48634e840e2598336887fa5be1dd9c82/types_aiofiles-25.1.0.20260508-py3-none-any.whl", hash = "sha256:c35d2be25a7e4b881da7f62ff3823db3770b2f704f31ac69681c227569e808cc", size = 14365, upload-time = "2026-05-08T04:49:47.302Z" }, +] + +[[package]] +name = "types-beautifulsoup4" +version = "4.12.0.20250516" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-html5lib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/d1/32b410f6d65eda94d3dfb0b3d0ca151f12cb1dc4cef731dcf7cbfd8716ff/types_beautifulsoup4-4.12.0.20250516.tar.gz", hash = "sha256:aa19dd73b33b70d6296adf92da8ab8a0c945c507e6fb7d5db553415cc77b417e", size = 16628, upload-time = "2025-05-16T03:09:09.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/79/d84de200a80085b32f12c5820d4fd0addcbe7ba6dce8c1c9d8605e833c8e/types_beautifulsoup4-4.12.0.20250516-py3-none-any.whl", hash = "sha256:5923399d4a1ba9cc8f0096fe334cc732e130269541d66261bb42ab039c0376ee", size = 16879, upload-time = "2025-05-16T03:09:09.051Z" }, +] + +[[package]] +name = "types-decorator" +version = "5.2.0.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/42/88861ae7467752fa2933cdccc562b63b29bbc000fe841c56c1f87958cf9b/types_decorator-5.2.0.20260508.tar.gz", hash = "sha256:83af4c212bdccf0cef593b210051beb451664a2ee304c96cae8eea25f98f04e6", size = 9212, upload-time = "2026-05-08T04:48:34.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/23/e975683499a548cd6e8522279e73e5ba501401fe417f2fe6babd52ab9b6a/types_decorator-5.2.0.20260508-py3-none-any.whl", hash = "sha256:7d5fb96ea6f959c1685c85fc76aaf186e4695a68e5cbd05dad9b10073e94be12", size = 8066, upload-time = "2026-05-08T04:48:34.011Z" }, +] + +[[package]] +name = "types-docker" +version = "7.1.0.20260512" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-paramiko" }, + { name = "types-requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/48/88f15e1ee1b561006b3c37ffe14d954a1fd15d1e87ed2d9206bb7ae82485/types_docker-7.1.0.20260512.tar.gz", hash = "sha256:d05ce1162267f769b26cbc8f38d7e9712e86816a37f06d4eab16279dbcf18456", size = 33799, upload-time = "2026-05-12T05:29:42.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/d3/0116c3b699d5060f77af1a5bc64cd776ec3d788b675d8e9e68899a317d0e/types_docker-7.1.0.20260512-py3-none-any.whl", hash = "sha256:cde75a5a299c504f13dfb681c3dffdb14501c2d261ebff9c9bb5cf0d89971506", size = 48123, upload-time = "2026-05-12T05:29:41.602Z" }, +] + +[[package]] +name = "types-docutils" +version = "0.22.3.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/49/48a386fe15539556de085b87a69568b028cca2fa4b92596a3d4f79ac6784/types_docutils-0.22.3.20260408.tar.gz", hash = "sha256:22d5d45e4e0d65a1bc8280987a73e28669bb1cc9d16b18d0afc91713d1be26da", size = 57383, upload-time = "2026-04-08T04:27:26.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/47/1667fda6e9fcb044f8fb797f6dc4367b88dc2ab40f1a035e387f5405e870/types_docutils-0.22.3.20260408-py3-none-any.whl", hash = "sha256:2545a86966022cdf1468d430b0007eba0837be77974a7f3fafa1b04a6815d531", size = 91981, upload-time = "2026-04-08T04:27:25.934Z" }, +] + +[[package]] +name = "types-html5lib" +version = "1.1.11.20260408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/59/914d00107c770e49fa57d4c4572e0371bbce14321385fd2ea3e06691b62d/types_html5lib-1.1.11.20260408.tar.gz", hash = "sha256:8a281aa367bc77dbc758358cd9bef79530f2d154eeed9b33705bb035a0dab9e4", size = 18316, upload-time = "2026-04-08T04:35:49.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/19/12d95e98e42e120522665ec6850b38df8d2c1cca94e21c4d7f8578acb64e/types_html5lib-1.1.11.20260408-py3-none-any.whl", hash = "sha256:d18dc4b90d6d6745585790b920db13ede43e1f8ff6ee1ac0ceb0dec4223a06fa", size = 24313, upload-time = "2026-04-08T04:35:48.679Z" }, +] + +[[package]] +name = "types-openpyxl" +version = "3.1.5.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/25/86379c5b2d83235984b2514168bf1c04deb54adbb9e0ab0a2abc3809dfa2/types_openpyxl-3.1.5.20260508.tar.gz", hash = "sha256:34f5c6398f9066bdad309661f300590fdd090846f76164002110a97327173d3d", size = 101398, upload-time = "2026-05-08T04:48:22.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/82/19cdc3d26f18c0848ad53ccc216e8bdcdd60a3c22c60cd47499f8e2dcd2d/types_openpyxl-3.1.5.20260508-py3-none-any.whl", hash = "sha256:603e41a524bb4f34e2ccfee63f5fe1f166d75387d36b1060183242f0958ec539", size = 165593, upload-time = "2026-05-08T04:48:20.834Z" }, +] + +[[package]] +name = "types-paramiko" +version = "4.0.0.20260408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/f5/2a556b03ba264508b6bc6a65131500265f210ff3ebf5d76dbe51b53c3979/types_paramiko-4.0.0.20260408.tar.gz", hash = "sha256:978191a2e11064fa4c7f9ada0fccf49159a17beb98b780310dd2c2d2b4106063", size = 29116, upload-time = "2026-04-08T04:35:04.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e2/cf451598a6a8820139d021b2be08a836b9b905d744bcc73b72172e7e10b3/types_paramiko-4.0.0.20260408-py3-none-any.whl", hash = "sha256:350bf53edb4eb88181be68854d598e1cc3a8764fe905d49913025b86e831adbc", size = 38816, upload-time = "2026-04-08T04:35:03.503Z" }, +] + +[[package]] +name = "types-pexpect" +version = "4.9.0.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/e4/c6a3db57e1559ecd588cb8df809a6e29cbc9d8bd52b5357d0f6853cd5930/types_pexpect-4.9.0.20260508.tar.gz", hash = "sha256:1729c05eb86c3f2bf76652576fb9f632921b3392ed951a681f3b4457d78af198", size = 13513, upload-time = "2026-05-08T04:49:24.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/20/8592e3e3ceb6147101a8803cda4be5433da83a56dc562b8dc1c55e9ccbe3/types_pexpect-4.9.0.20260508-py3-none-any.whl", hash = "sha256:a215c07a3d317597b5dcf6ed6892c683a20c8488b2cd37001c885497928a8bdf", size = 17077, upload-time = "2026-05-08T04:49:22.811Z" }, +] + +[[package]] +name = "types-pillow" +version = "10.2.0.20240822" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/4a/4495264dddaa600d65d68bcedb64dcccf9d9da61adff51f7d2ffd8e4c9ce/types-Pillow-10.2.0.20240822.tar.gz", hash = "sha256:559fb52a2ef991c326e4a0d20accb3bb63a7ba8d40eb493e0ecb0310ba52f0d3", size = 35389, upload-time = "2024-08-22T02:32:48.15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/23/e81a5354859831fcf54d488d33b80ba6133ea84f874a9c0ec40a4881e133/types_Pillow-10.2.0.20240822-py3-none-any.whl", hash = "sha256:d9dab025aba07aeb12fd50a6799d4eac52a9603488eca09d7662543983f16c5d", size = 54354, upload-time = "2024-08-22T02:32:46.664Z" }, +] + +[[package]] +name = "types-psutil" +version = "7.2.2.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/cc/5ac56357b08655ff93106d6391b72d50c47416a044041312550bcd806827/types_psutil-7.2.2.20260508.tar.gz", hash = "sha256:8cfd8339f5e898570f80486423e65d87558d89d0181bf723d20ac5e778fe218e", size = 26575, upload-time = "2026-05-08T04:46:48.388Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/44/4467583df75313c28abaadfab12f102fba9db7285323e75601acf936d996/types_psutil-7.2.2.20260508-py3-none-any.whl", hash = "sha256:b142452e0953f2d07dbdbb98d81f3a629f5906cc2d94bb7e34da0fba55fbab4a", size = 32777, upload-time = "2026-05-08T04:46:46.972Z" }, +] + +[[package]] +name = "types-psycopg2" +version = "2.9.21.20260509" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/9e/a78cffd63e14c45fc05664c42739b2b15938acff92ca59e22b22ced5695e/types_psycopg2-2.9.21.20260509.tar.gz", hash = "sha256:0422105f691a409e9d8048c2205aca9d694b70823248c6614393444017e9f088", size = 27215, upload-time = "2026-05-09T04:58:40.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b3/f4d11df63430d7e14b2889caf73f6f6bf9147a51d866ce2da4c07d9f3c8b/types_psycopg2-2.9.21.20260509-py3-none-any.whl", hash = "sha256:69f6dae384bbea830dff23621936423035db152af901331e6f9c46f7c4f4b24f", size = 24940, upload-time = "2026-05-09T04:58:39.208Z" }, +] + +[[package]] +name = "types-pyasn1" +version = "0.6.0.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/c0/02f897fc8543f64fa6b1ca6a30d388e37c4ec2f761f469a2d9a29b89cdef/types_pyasn1-0.6.0.20260408.tar.gz", hash = "sha256:32dc90927adbe504fd2eee83ae30cf5ef934e5db0d1d94886071fed47eb50c8c", size = 17312, upload-time = "2026-04-08T04:27:16.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/473e06d5aaec3730aab5a9d40c2044e673c927412c24bd7f3fa0df7e95d3/types_pyasn1-0.6.0.20260408-py3-none-any.whl", hash = "sha256:ee7fbd98bce61193c5d4f8f7812fa53cddc5b8cc5ceb9fcda6eea539947c6d6b", size = 24044, upload-time = "2026-04-08T04:27:16.002Z" }, +] + +[[package]] +name = "types-pycurl" +version = "7.46.0.20260509" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/3e/9b7e3779b7baf42d650ccc9943f3fd620207446bbdd5103c6a2be3bf2fbb/types_pycurl-7.46.0.20260509.tar.gz", hash = "sha256:719d328744d0a0f1765c7a2eb3e3b081edc8ddec4ca668b61e82625a4519b359", size = 16277, upload-time = "2026-05-09T04:58:55.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/b9/9635032e42e92d466edbed314d1ddbaf0e0dedc81bed4083e09f44a71799/types_pycurl-7.46.0.20260509-py3-none-any.whl", hash = "sha256:9399a9e0c2682e8740a8027bbc76e4510380bc71165d27c9f6107e5042926334", size = 14351, upload-time = "2026-05-09T04:58:53.891Z" }, +] + +[[package]] +name = "types-pygments" +version = "2.20.0.20260508" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-docutils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/9c/b5c7f3d26108e88594d5d5fa0aaae7edab0ad3e90746cb212a1548f58820/types_pygments-2.20.0.20260508.tar.gz", hash = "sha256:6ec4e232784e427eea12e798fe28d5a28321023937795527a94582d5f56feed7", size = 21102, upload-time = "2026-05-08T04:50:34.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/e6/767672dd4df860550da0aade55ea5b4e6244b3f58edc209f0bb36dca55f2/types_pygments-2.20.0.20260508-py3-none-any.whl", hash = "sha256:42d9c45f3397a46bdb7bf718d9fe9c2d001c842ffa5afe748a4d7a5b5f0708fe", size = 28989, upload-time = "2026-05-08T04:50:33.354Z" }, +] + +[[package]] +name = "types-pymysql" +version = "1.1.0.20260508" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/1a/3dd6633ddc5fda8b81914d768b45378fd874ae5c60509c46a162e8593a9f/types_pymysql-1.1.0.20260508.tar.gz", hash = "sha256:49abf0c2f8d944384834544a8f25ea1b25018c3d815f7ec676f73d0b7fe0731c", size = 22399, upload-time = "2026-05-08T04:46:51.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/43/01afb22053493050209299ac830dfc2d2a0490b7c6b17fafc234a352bc5e/types_pymysql-1.1.0.20260508-py3-none-any.whl", hash = "sha256:a726e6f867e0a1027a297c89d7646e4d52b7db6ccd11b2a40780120257610c78", size = 23069, upload-time = "2026-05-08T04:46:50.802Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, +] + +[[package]] +name = "types-tqdm" +version = "4.67.3.20260508" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/d9/add71c78db72e934747f7467ffe7b8fa9f3e9fb38ffa5377d5dd390ac036/types_tqdm-4.67.3.20260508.tar.gz", hash = "sha256:9acfdd179bdf5cc81f7ce7353b5b85eb92b16667bba89ec6c187b5e7ce617986", size = 18141, upload-time = "2026-05-08T04:52:34.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/64/e66c98e951deb5985fbff40a11ba0a1f0528505e0734fa6c39534fc0b113/types_tqdm-4.67.3.20260508-py3-none-any.whl", hash = "sha256:0440759cc861a90c1cc98870f2c15ac633c0b6b14651dcafb83f98ab83bad0f4", size = 24546, upload-time = "2026-05-08T04:52:33.995Z" }, +] + +[[package]] +name = "types-ujson" +version = "5.10.0.20250822" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/bd/d372d44534f84864a96c19a7059d9b4d29db8541828b8b9dc3040f7a46d0/types_ujson-5.10.0.20250822.tar.gz", hash = "sha256:0a795558e1f78532373cf3f03f35b1f08bc60d52d924187b97995ee3597ba006", size = 8437, upload-time = "2025-08-22T03:02:19.433Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/f2/d812543c350674d8b3f6e17c8922248ee3bb752c2a76f64beb8c538b40cf/types_ujson-5.10.0.20250822-py3-none-any.whl", hash = "sha256:3e9e73a6dc62ccc03449d9ac2c580cd1b7a8e4873220db498f7dd056754be080", size = 7657, upload-time = "2025-08-22T03:02:18.699Z" }, +] + +[[package]] +name = "types-webencodings" +version = "0.5.0.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/d2/21567fac142315580ce3ee37d08a4e8819921dae833bbcd27b9f8b373799/types_webencodings-0.5.0.20260408.tar.gz", hash = "sha256:28c596619f367e43eee393d85f63e8d2fdb6874c654a8d441c37f8afe29c6d0d", size = 7504, upload-time = "2026-04-08T04:28:51.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/e4/f13be8f6d9a561166f7d963012d0ccc833e13aee3044c4f1a8fb1fee462a/types_webencodings-0.5.0.20260408-py3-none-any.whl", hash = "sha256:19a2afe5c22d9b1e880b49ff823c7b531f473a390fe47ac903c0bdb5cd677dd9", size = 8717, upload-time = "2026-04-08T04:28:50.943Z" }, +] + +[[package]] +name = "types-xlrd" +version = "2.0.0.20260408" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/41/0045814e0f0676e3526453a4e5beb4914aae354126a7de7225c6c2d7568e/types_xlrd-2.0.0.20260408.tar.gz", hash = "sha256:f86341825dcecc82c9eb4955308a9e7ea07d9d7290aca9a8f5373d7cdf172a2c", size = 13984, upload-time = "2026-04-08T04:29:05.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/3d/fa50879aad7e63d1b0d4fe6ea97db0e1a7141a223f6f8d8eafa9df5033a8/types_xlrd-2.0.0.20260408-py3-none-any.whl", hash = "sha256:a140966631a855d69d69aa8842526123f2aaa1f855338083e361561a266a6f5a", size = 16879, upload-time = "2026-04-08T04:29:05.143Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uv" +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, + { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, +] + +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + +[[package]] +name = "yamllint" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathspec" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a0/8fc2d68e132cf918f18273fdc8a1b8432b60d75ac12fdae4b0ef5c9d2e8d/yamllint-1.38.0.tar.gz", hash = "sha256:09e5f29531daab93366bb061e76019d5e91691ef0a40328f04c927387d1d364d", size = 142446, upload-time = "2026-01-13T07:47:53.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/92/aed08e68de6e6a3d7c2328ce7388072cd6affc26e2917197430b646aed02/yamllint-1.38.0-py3-none-any.whl", hash = "sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220", size = 68940, upload-time = "2026-01-13T07:47:51.343Z" }, +] From 2639b7419e9194281ae68e89927c327ac39d3c82 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 00:45:50 -0600 Subject: [PATCH 07/30] fix(runtime): bound parent code-intel refresh --- go/cmd/coding-ethos-run/args.go | 2 ++ go/cmd/coding-ethos-run/main_test.go | 30 +++++++++++++++++++++ go/internal/hooklog/runner.go | 8 +++--- go/internal/hooklog/runner_internal_test.go | 3 +++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/go/cmd/coding-ethos-run/args.go b/go/cmd/coding-ethos-run/args.go index 2def0129..ad9501d5 100644 --- a/go/cmd/coding-ethos-run/args.go +++ b/go/cmd/coding-ethos-run/args.go @@ -268,6 +268,8 @@ func (paths runtimePaths) withCommandRoots(args []string) runtimePaths { repoRoot = flagValue(args[1:], "--root", paths.Root) case "runtime-policy": repoRoot = flagValue(args[1:], "--repo", paths.Root) + case "parent-install", "parent-check", "parent-lint": + repoRoot = flagValue(args[1:], "--repo", paths.Root) default: return paths } diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index aa4246e2..f5d43aa8 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -396,6 +396,36 @@ func TestWithCommandRootsUsesCommandSpecificRepositoryFlags(t *testing.T) { "/private/state", }, }, + { + name: "parent install", + args: []string{ + "parent-install", + "--repo", + "/repo", + "--state-root", + "/private/state", + }, + }, + { + name: "parent check", + args: []string{ + "parent-check", + "--repo", + "/repo", + "--state-root", + "/private/state", + }, + }, + { + name: "parent lint", + args: []string{ + "parent-lint", + "--repo", + "/repo", + "--state-root", + "/private/state", + }, + }, } { t.Run(testCase.name, func(t *testing.T) { t.Parallel() diff --git a/go/internal/hooklog/runner.go b/go/internal/hooklog/runner.go index 38f202a9..0f0e1247 100644 --- a/go/internal/hooklog/runner.go +++ b/go/internal/hooklog/runner.go @@ -257,10 +257,10 @@ func autoPruneHookRuns(root string) error { } func shouldForceCodeIntelRefresh(command []string) bool { - return commandContains(command, "parent-install") || - commandContains(command, "parent-check") || - commandContains(command, "parent-lint") || - commandContains(command, "policy-lint") || + // Parent workflows refresh their explicit --repo target as part of the + // workflow itself. Repeating that work here both doubles maintenance and, + // before command-root resolution, could index the invocation repository. + return commandContains(command, "policy-lint") || commandContainsSequence(command, "git-hook", "pre-commit") || commandContainsSequence(command, "git-hook", "pre-push") || commandContainsSequence(command, "make", "pre-commit") || diff --git a/go/internal/hooklog/runner_internal_test.go b/go/internal/hooklog/runner_internal_test.go index 35030e40..725a8f32 100644 --- a/go/internal/hooklog/runner_internal_test.go +++ b/go/internal/hooklog/runner_internal_test.go @@ -12,6 +12,9 @@ func TestShouldForceCodeIntelRefreshSkipsManagedToolRuns(t *testing.T) { {"coding-ethos-run", "policy-tool", "go-test", "go"}, {"coding-ethos-run", "policy-tool", "ruff", "check", "pkg"}, {"coding-ethos-run", "policy-tool-group", "type_check"}, + {"coding-ethos-run", "parent-install", "--repo", "/repo"}, + {"coding-ethos-run", "parent-check", "--repo", "/repo"}, + {"coding-ethos-run", "parent-lint", "--repo", "/repo"}, } { if shouldForceCodeIntelRefresh(command) { t.Fatalf("shouldForceCodeIntelRefresh(%#v) = true, want false", command) From 13fa734d5bf5332629919662e467cb40099c66ea Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 01:19:30 -0600 Subject: [PATCH 08/30] fix(runtime): keep parent artifact checks bounded --- README.md | 5 +++++ go/cmd/coding-ethos-run/parent_workflow.go | 18 ------------------ 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d48180a0..155aef40 100644 --- a/README.md +++ b/README.md @@ -936,6 +936,11 @@ projects byte-identical executables into the parent repository's stable common Git runtime. `parent-check` hashes both sides and fails if that projection is missing, non-executable, symlinked back to a retiring checkout, or stale. +Parent install and check are artifact workflows and do not perform a full +repository code-intel refresh. Lint and Git-hook workflows refresh code intel +when source analysis is actually part of the requested gate, so deploying or +checking the runtime cannot be delayed by an unrelated whole-repository scan. + When `parent-install` or `parent-lint` receives an external `--state-root`, it leaves the consumer checkout's tracked `.gitignore` unchanged. Other generated parent artifacts remain normal consumer surfaces; repo-local state retains the diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index 2b87b48d..a14d6947 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -20,7 +20,6 @@ import ( "blackcat.ca/coding-ethos/go/internal/agenthooks" "blackcat.ca/coding-ethos/go/internal/agentskills" "blackcat.ca/coding-ethos/go/internal/apperror" - "blackcat.ca/coding-ethos/go/internal/codeintel" "blackcat.ca/coding-ethos/go/internal/feedback" "blackcat.ca/coding-ethos/go/internal/geminiprompts" "blackcat.ca/coding-ethos/go/internal/hookoutput" @@ -289,10 +288,6 @@ func syncParentArtifacts( })) } - steps = append(steps, runParentStep("code_intel", func() error { - return refreshParentCodeIntel(options.Repo) - })) - return steps } @@ -345,22 +340,9 @@ func checkParentArtifacts( steps = append(steps, runParentStep("agent_hooks", func() error { return agenthooks.DoctorSettings(options.Repo, parentAgentHookCommand(paths)) })) - steps = append(steps, runParentStep("code_intel", func() error { - return refreshParentCodeIntel(options.Repo) - })) - return steps } -func refreshParentCodeIntel(repo string) error { - _, err := codeintel.RefreshRepository(context.Background(), repo, []string{"."}) - if err != nil { - return fmt.Errorf("refresh code-intel: %w", err) - } - - return nil -} - func syncParentPolicyBundle(paths runtimePaths, options parentWorkflowOptions) error { bundle, metadata, err := compileParentPolicyBundle(paths, options, "") if err != nil { From 3549929082418da1604a89a76bd7c9db7ad12c6c Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 01:25:03 -0600 Subject: [PATCH 09/30] fix(runtime): satisfy parent workflow lint --- go/cmd/coding-ethos-run/parent_workflow.go | 1 + 1 file changed, 1 insertion(+) diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index a14d6947..77619275 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -340,6 +340,7 @@ func checkParentArtifacts( steps = append(steps, runParentStep("agent_hooks", func() error { return agenthooks.DoctorSettings(options.Repo, parentAgentHookCommand(paths)) })) + return steps } From 51aca249fa1212400e413128291fdec943b84c58 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 09:31:55 -0600 Subject: [PATCH 10/30] fix(policy): unblock verified consumer commits --- README.md | 12 ++ go/internal/evaluators/git_staged_admin.go | 104 ++++++++++++++++++ .../evaluators/git_staged_admin_test.go | 87 ++++++++++++++- .../evaluators/shell_best_practices.go | 22 ++++ .../evaluators/shell_best_practices_test.go | 50 +++++++++ go/internal/policy/compiler_policies.go | 15 ++- 6 files changed, 283 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 155aef40..7ee9a8f2 100644 --- a/README.md +++ b/README.md @@ -1867,6 +1867,18 @@ bin/coding-ethos-run policy-git --admin-approved commit -F /tmp/msg The flag only changes `git.staged_admin_files` from block to record. It does not disable other policy and is invalid outside this repository. +In consumer repositories, an admin-classified file that is also a generated +tool-config surface may be committed by an agent only when its staged bytes +exactly match the output rendered from the active Coding Ethos policy. The +comparison is against the Git index, so restoring only the working-tree copy +cannot conceal a divergent staged config. Any hand-edited or stale config +remains admin-blocked. + +The optional shell common-helper convention is enforced only when the consumer +actually tracks a `common.sh` helper. A repository without that convention is +still checked for shebangs, strict mode, syntax, and unsafe shell constructs, +but is not told to source a nonexistent file. + Agents must not use `/usr/bin/git` or any other raw Git path for this workflow. ## Development diff --git a/go/internal/evaluators/git_staged_admin.go b/go/internal/evaluators/git_staged_admin.go index 18488115..48d0d236 100644 --- a/go/internal/evaluators/git_staged_admin.go +++ b/go/internal/evaluators/git_staged_admin.go @@ -4,6 +4,8 @@ package evaluators import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" @@ -12,6 +14,7 @@ import ( "blackcat.ca/coding-ethos/go/diagnostics" "blackcat.ca/coding-ethos/go/internal/policy" "blackcat.ca/coding-ethos/go/internal/shellquote" + "blackcat.ca/coding-ethos/go/internal/toolconfigs" ) func defaultAdminOnlyBasenames() []string { @@ -51,6 +54,32 @@ func EvaluateGitStagedAdminFiles( return nil, nil } + verifiedGenerated, blockedFiles, err := verifiedGeneratedAdminFiles( + context, + blockedFiles, + ) + if err != nil { + return nil, err + } + + if len(blockedFiles) == 0 { + decision := policy.NewDecision(recordDecision, policyDef) + decision.Severity = recordDecision + decision.Message = "Administrative staged files match generated tool policy." + decision.Evidence = stagedAdminEvidence(verifiedGenerated, context.Cwd) + decision.Evidence["verified_generated_files"] = append( + []string(nil), + verifiedGenerated..., + ) + decision.Diagnostics = verifiedGeneratedAdminDiagnostics( + policyDef, + verifiedGenerated, + decision, + ) + + return []policy.Decision{decision}, nil + } + mergeParent, inheritedFiles, divergentFiles := mergeParentAdminFiles( context.Cwd, blockedFiles, @@ -86,6 +115,60 @@ func EvaluateGitStagedAdminFiles( return []policy.Decision{decision}, nil } +func verifiedGeneratedAdminFiles( + context Context, + files []string, +) ([]string, []string, error) { + ethosRoot := stringOption(context.EvaluatorOptions, "ethos_root", "") + if ethosRoot == "" || context.Cwd == "" { + return nil, append([]string(nil), files...), nil + } + + artifacts, err := toolconfigs.StateArtifacts(ethosRoot, context.Cwd, "") + if err != nil { + return nil, nil, fmt.Errorf( + "render generated admin-file policy: %w", + err, + ) + } + + expected := make(map[string]string, len(artifacts)) + for _, artifact := range artifacts { + expected[filepath.ToSlash(filepath.Clean(artifact.Path))] = artifact.ExpectedSHA256 + } + + verified := make([]string, 0, len(files)) + blocked := make([]string, 0, len(files)) + + for _, file := range files { + normalized := filepath.ToSlash(filepath.Clean(file)) + + expectedHash, generated := expected[normalized] + if !generated { + blocked = append(blocked, file) + + continue + } + + staged, readErr := GitCommand(context.Cwd, "show", ":"+normalized).Output() + if readErr != nil || sha256String(staged) != expectedHash { + blocked = append(blocked, file) + + continue + } + + verified = append(verified, file) + } + + return verified, blocked, nil +} + +func sha256String(content []byte) string { + sum := sha256.Sum256(content) + + return "sha256:" + hex.EncodeToString(sum[:]) +} + func mergeParentAdminFiles(cwd string, files []string) (string, []string, []string) { divergent := append([]string(nil), files...) @@ -270,6 +353,27 @@ func inheritedAdminDiagnostics( return items } +func verifiedGeneratedAdminDiagnostics( + policyDef policy.Policy, + files []string, + decision policy.Decision, +) []diagnostics.Diagnostic { + items := make([]diagnostics.Diagnostic, 0, len(files)) + for _, file := range files { + items = append(items, diagnostics.Diagnostic{ + Tool: "git", + File: file, + PolicyID: policyDef.ID, + Severity: decision.Severity, + Message: "Administrative staged file matches generated tool policy.", + Advice: "Keep the generated tool configuration unchanged.", + PrincipleIDs: append([]string(nil), decision.PrincipleIDs...), + }) + } + + return items +} + func stagedFiles(cwd string) ([]string, error) { cmd := GitCommand(cwd, "diff", "--cached", "--name-only") diff --git a/go/internal/evaluators/git_staged_admin_test.go b/go/internal/evaluators/git_staged_admin_test.go index 4628f634..63965586 100644 --- a/go/internal/evaluators/git_staged_admin_test.go +++ b/go/internal/evaluators/git_staged_admin_test.go @@ -169,6 +169,81 @@ func TestEvaluateGitStagedAdminFilesRecordsWithAdminApproval(t *testing.T) { } } +func TestEvaluateGitStagedAdminFilesRecordsExactGeneratedConfig(t *testing.T) { + t.Parallel() + + ethosRoot, repo := syncedGeneratedConfigRepo(t) + initializeStagedAdminGitRepo(t, repo) + runGit(t, repo, "add", ".pylintrc") + + decisions, err := EvaluateGitStagedAdminFiles( + stagedAdminPolicy(), + Context{ + Argv: []string{"git", "commit", "-m", "generated config"}, + Cwd: repo, + EvaluatorOptions: map[string]any{ + "ethos_root": ethosRoot, + }, + }, + ) + if err != nil { + t.Fatalf("evaluate staged generated admin file: %v", err) + } + + if len(decisions) != 1 || decisions[0].Decision != recordDecision { + t.Fatalf("decision mismatch: %#v", decisions) + } + assertStringSliceEvidence( + t, + decisions[0].Evidence, + "verified_generated_files", + []string{".pylintrc"}, + ) +} + +func TestEvaluateGitStagedAdminFilesBlocksDivergentStagedGeneratedConfig( + t *testing.T, +) { + t.Parallel() + + ethosRoot, repo := syncedGeneratedConfigRepo(t) + initializeStagedAdminGitRepo(t, repo) + pylintPath := filepath.Join(repo, ".pylintrc") + expected, err := os.ReadFile(pylintPath) + if err != nil { + t.Fatalf("read generated Pylint config: %v", err) + } + if err = os.WriteFile( + pylintPath, + []byte("[MAIN]\nignore-patterns=.*\n"), + 0o600, + ); err != nil { + t.Fatalf("write divergent Pylint config: %v", err) + } + runGit(t, repo, "add", ".pylintrc") + if err = os.WriteFile(pylintPath, expected, 0o600); err != nil { + t.Fatalf("restore working-tree Pylint config: %v", err) + } + + decisions, err := EvaluateGitStagedAdminFiles( + stagedAdminPolicy(), + Context{ + Argv: []string{"git", "commit", "-m", "divergent config"}, + Cwd: repo, + EvaluatorOptions: map[string]any{ + "ethos_root": ethosRoot, + }, + }, + ) + if err != nil { + t.Fatalf("evaluate divergent staged generated admin file: %v", err) + } + + if len(decisions) != 1 || decisions[0].Decision != blockDecision { + t.Fatalf("decision mismatch: %#v", decisions) + } +} + func TestEvaluateGitStagedAdminFilesRecordsAdminFileInheritedFromMergeParent( t *testing.T, ) { @@ -295,9 +370,7 @@ func stagedAdminRepo(t *testing.T) string { t.Helper() repo := t.TempDir() - runGit(t, repo, "init") - runGit(t, repo, "config", "user.email", "test@example.com") - runGit(t, repo, "config", "user.name", "Test") + initializeStagedAdminGitRepo(t, repo) hookPath := filepath.Join(repo, "bin") @@ -320,6 +393,14 @@ func stagedAdminRepo(t *testing.T) string { return repo } +func initializeStagedAdminGitRepo(t *testing.T, repo string) { + t.Helper() + + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "Test") +} + func stagedAdminMergeRepo(t *testing.T) string { t.Helper() diff --git a/go/internal/evaluators/shell_best_practices.go b/go/internal/evaluators/shell_best_practices.go index c9e6ae72..f6f6c3e5 100644 --- a/go/internal/evaluators/shell_best_practices.go +++ b/go/internal/evaluators/shell_best_practices.go @@ -43,6 +43,9 @@ func EvaluateShellBestPractices( "require_common_for_prefixes", []string{"scripts/"}, ) + if !repositoryHasTrackedCommonShellHelper(context.Cwd) { + requireCommon = nil + } for _, file := range context.Files { if !looksLikeShellFile(file) { @@ -69,6 +72,25 @@ func EvaluateShellBestPractices( return nil, nil } +func repositoryHasTrackedCommonShellHelper(cwd string) bool { + if strings.TrimSpace(cwd) == "" { + return false + } + + output, err := GitCommand(cwd, "ls-files", "--cached").Output() + if err != nil { + return false + } + + for line := range strings.SplitSeq(string(output), "\n") { + if filepath.Base(strings.TrimSpace(line)) == "common.sh" { + return true + } + } + + return false +} + func looksLikeShellFile(path string) bool { base := filepath.Base(path) switch strings.ToLower(filepath.Ext(path)) { diff --git a/go/internal/evaluators/shell_best_practices_test.go b/go/internal/evaluators/shell_best_practices_test.go index 676c322e..3ebab1a4 100644 --- a/go/internal/evaluators/shell_best_practices_test.go +++ b/go/internal/evaluators/shell_best_practices_test.go @@ -88,6 +88,56 @@ func TestEvaluateShellBestPracticesBlocksInvalidShellSyntaxWithLocation(t *testi } } +func TestEvaluateShellBestPracticesRequiresOnlyExistingCommonHelper(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + initializeStagedAdminGitRepo(t, repo) + scriptsDir := filepath.Join(repo, "scripts") + if err := os.MkdirAll(scriptsDir, 0o700); err != nil { + t.Fatalf("create scripts directory: %v", err) + } + + scriptPath := filepath.Join(scriptsDir, "work.sh") + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + if err := os.WriteFile(scriptPath, content, 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + context := Context{ + Cwd: repo, + Files: []string{scriptPath}, + EvaluatorOptions: map[string]any{ + "require_common_for_prefixes": []any{scriptsDir + string(filepath.Separator)}, + }, + } + decisions, err := EvaluateShellBestPractices(shellBestPracticesPolicy(), context) + if err != nil { + t.Fatalf("evaluate without common helper: %v", err) + } + if len(decisions) != 0 { + t.Fatalf("missing common helper must disable convention: %#v", decisions) + } + + commonPath := filepath.Join(scriptsDir, "common.sh") + if err = os.WriteFile(commonPath, content, 0o600); err != nil { + t.Fatalf("write common helper: %v", err) + } + runGit(t, repo, "add", "scripts/common.sh") + + decisions, err = EvaluateShellBestPractices(shellBestPracticesPolicy(), context) + if err != nil { + t.Fatalf("evaluate with common helper: %v", err) + } + if len(decisions) != 1 { + t.Fatalf("existing common helper must activate convention: %#v", decisions) + } + if got := decisions[0].Diagnostics[0].Message; got != + "scripts/ shell files must source the repository common shell helpers" { + t.Fatalf("diagnostic message = %q", got) + } +} + func shellBestPracticesPolicy() policy.Policy { return policy.Policy{ ID: "shell.best_practices", diff --git a/go/internal/policy/compiler_policies.go b/go/internal/policy/compiler_policies.go index 5e5748e9..b42a78fe 100644 --- a/go/internal/policy/compiler_policies.go +++ b/go/internal/policy/compiler_policies.go @@ -42,7 +42,7 @@ func compilePolicies( ) (map[string]Policy, error) { policies := map[string]Policy{} addConfiguredPythonPolicies(policies, config, principles) - addGitPolicies(policies, config, principles) + addGitPolicies(policies, config, principles, configSourceRoot) addSyntaxPolicies(policies, config, principles) addShellPolicies(policies, config, principles) addProxyPolicies(policies, config) @@ -80,8 +80,9 @@ func addGitPolicies( policies map[string]Policy, config map[string]any, principles map[string]Principle, + configSourceRoot string, ) { - for _, policy := range gitPolicies(config, principles) { + for _, policy := range gitPolicies(config, principles, configSourceRoot) { policies[policy.ID] = policy } @@ -103,9 +104,13 @@ func addGitPolicies( } } -func gitPolicies(config map[string]any, principles map[string]Principle) []Policy { +func gitPolicies( + config map[string]any, + principles map[string]Principle, + configSourceRoot string, +) []Policy { return []Policy{ - gitStagedAdminPolicy(config, principles), + gitStagedAdminPolicy(config, principles, configSourceRoot), gitCommitHeadPolicy(principles), } } @@ -113,6 +118,7 @@ func gitPolicies(config map[string]any, principles map[string]Principle) []Polic func gitStagedAdminPolicy( config map[string]any, principles map[string]Principle, + configSourceRoot string, ) Policy { return Policy{ ID: "git.staged_admin_files", @@ -139,6 +145,7 @@ func gitStagedAdminPolicy( Kind: "git_state", Name: "git.staged_admin_files", Options: map[string]any{ + "ethos_root": configSourceRoot, "basenames": stringSliceAt( config, []string{"git", "staged_admin_files", "basenames"}, From ea10cf72c0626a8e95aa398a61077d314fb332da Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 09:52:50 -0600 Subject: [PATCH 11/30] fix(memory): bound provider project keys --- go/internal/memories/memory.go | 26 +++++++++++++++++++++++++- go/internal/memories/memory_test.go | 19 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/go/internal/memories/memory.go b/go/internal/memories/memory.go index 74356c2b..348031ac 100644 --- a/go/internal/memories/memory.go +++ b/go/internal/memories/memory.go @@ -13,6 +13,7 @@ import ( "slices" "strings" "time" + "unicode/utf8" "go.yaml.in/yaml/v3" @@ -29,6 +30,7 @@ const ( defaultIndexName = "index.yaml" defaultLockName = ".lock" + maxProjectKeyLen = 240 fileMode = 0o600 dirMode = 0o700 ) @@ -631,7 +633,29 @@ func claudeProjectKey(root string) string { replacer := strings.NewReplacer("/", "-", "_", "-", ".", "-") - return replacer.Replace(key) + return boundedClaudeProjectKey(replacer.Replace(key)) +} + +func boundedClaudeProjectKey(key string) string { + if len(key) <= maxProjectKeyLen { + return key + } + + sum := sha256.Sum256([]byte(key)) + suffix := "-" + hex.EncodeToString(sum[:16]) + prefixLimit := maxProjectKeyLen - len(suffix) + cut := prefixLimit + + for cut > 0 && !utf8.ValidString(key[:cut]) { + cut-- + } + + prefix := strings.TrimRight(key[:cut], "-") + if prefix == "" { + prefix = "project" + } + + return prefix + suffix } func normalizedMemoryPath(root, path string) string { diff --git a/go/internal/memories/memory_test.go b/go/internal/memories/memory_test.go index 8540846f..194e1e24 100644 --- a/go/internal/memories/memory_test.go +++ b/go/internal/memories/memory_test.go @@ -192,6 +192,25 @@ func TestImportExistingForRootsKeepsDurableStateOutOfSourceRoot(t *testing.T) { } } +func TestImportExistingForRootsBoundsLongClaudeProjectKey(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + sourceRoot := filepath.Join( + t.TempDir(), + strings.Repeat("authority-", 15), + strings.Repeat("sandbox-", 15), + ) + if err := os.MkdirAll(sourceRoot, 0o700); err != nil { + t.Fatalf("create long source root: %v", err) + } + + stateRoot := t.TempDir() + if _, err := memories.ImportExistingForRoots(sourceRoot, stateRoot); err != nil { + t.Fatalf("import with long Claude project key: %v", err) + } +} + func TestLoadSettingsMergesRepoOverride(t *testing.T) { t.Parallel() From e2af2ebe7f290f1062890e9b0446366635d6b7b2 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 10:38:20 -0600 Subject: [PATCH 12/30] fix(hooks): harden generated consumer commits --- README.md | 8 ++ go/internal/generatedtrust/staged.go | 112 ++++++++++++++++ go/internal/githookcli/main.go | 10 +- go/internal/hooklog/runner.go | 14 +- go/internal/hooklog/runner_internal_test.go | 12 ++ go/internal/lint/runner.go | 69 +++++++++- go/internal/lint/runner_test.go | 134 ++++++++++++++++++++ go/internal/lintcli/main.go | 12 +- go/internal/mcp/server.go | 16 ++- 9 files changed, 361 insertions(+), 26 deletions(-) create mode 100644 go/internal/generatedtrust/staged.go diff --git a/README.md b/README.md index 7ee9a8f2..0ed61ef2 100644 --- a/README.md +++ b/README.md @@ -940,6 +940,14 @@ Parent install and check are artifact workflows and do not perform a full repository code-intel refresh. Lint and Git-hook workflows refresh code intel when source analysis is actually part of the requested gate, so deploying or checking the runtime cannot be delayed by an unrelated whole-repository scan. +Failed or policy-blocked hooks retain their trace evidence but do not perform a +whole-repository refresh, because no accepted source transition occurred. + +Consumer commits may include generated tool and provider configuration only +when the staged Git-index bytes exactly match output rendered by the active +Coding Ethos authority. That narrow trust record exempts the generated surface +from path-write guards while every content policy still runs; restoring a clean +working-tree copy cannot conceal divergent staged bytes. When `parent-install` or `parent-lint` receives an external `--state-root`, it leaves the consumer checkout's tracked `.gitignore` unchanged. Other generated diff --git a/go/internal/generatedtrust/staged.go b/go/internal/generatedtrust/staged.go new file mode 100644 index 00000000..6972ae57 --- /dev/null +++ b/go/internal/generatedtrust/staged.go @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +// Package generatedtrust verifies staged generated surfaces against the active +// Coding Ethos authority before path-protection policies consider exemptions. +package generatedtrust + +import ( + "crypto/sha256" + "encoding/hex" + "path/filepath" + "sort" + "strings" + + "blackcat.ca/coding-ethos/go/internal/agenthooks" + "blackcat.ca/coding-ethos/go/internal/evaluators" + "blackcat.ca/coding-ethos/go/internal/policy" + "blackcat.ca/coding-ethos/go/internal/shellquote" + "blackcat.ca/coding-ethos/go/internal/syncstate" + "blackcat.ca/coding-ethos/go/internal/toolconfigs" +) + +// ExactStagedFiles returns only files whose Git index bytes exactly match an +// artifact rendered by the active authority. Rendering and index-read errors +// fail closed by omitting the affected artifact. +func ExactStagedFiles(bundle policy.Bundle, cwd string, files []string) []string { + if strings.TrimSpace(cwd) == "" || len(files) == 0 { + return nil + } + + ethosRoot := generatedConfigEthosRoot(bundle) + if ethosRoot == "" { + return nil + } + + artifacts := generatedArtifacts(ethosRoot, cwd) + staged := normalizedPathSet(files) + trusted := map[string]bool{} + + for _, artifact := range artifacts { + path := filepath.ToSlash(filepath.Clean(artifact.Path)) + if !staged[path] || artifact.ExpectedSHA256 == "" { + continue + } + + content, err := evaluators.GitCommand(cwd, "show", ":"+path).Output() + if err == nil && sha256Bytes(content) == artifact.ExpectedSHA256 { + trusted[path] = true + } + } + + result := make([]string, 0, len(trusted)) + for path := range trusted { + result = append(result, path) + } + + sort.Strings(result) + + return result +} + +func generatedArtifacts(ethosRoot, repoRoot string) []syncstate.Artifact { + artifacts := []syncstate.Artifact{} + + toolArtifacts, err := toolconfigs.StateArtifacts(ethosRoot, repoRoot, "") + if err == nil { + artifacts = append(artifacts, toolArtifacts...) + } + + hookCommand := shellquote.Command( + filepath.Join(ethosRoot, "bin", "coding-ethos-run"), + "agent-hook", + ) + + hookArtifacts, err := agenthooks.StateArtifacts(repoRoot, hookCommand) + if err == nil { + artifacts = append(artifacts, hookArtifacts...) + } + + return artifacts +} + +func generatedConfigEthosRoot(bundle policy.Bundle) string { + policyDef, found := bundle.Policies["generated_config.freshness"] + if !found { + return "" + } + + for _, evaluator := range policyDef.Evaluators { + value, ok := evaluator.Options["ethos_root"].(string) + if ok && strings.TrimSpace(value) != "" { + return filepath.Clean(value) + } + } + + return "" +} + +func normalizedPathSet(files []string) map[string]bool { + paths := make(map[string]bool, len(files)) + for _, file := range files { + paths[filepath.ToSlash(filepath.Clean(file))] = true + } + + return paths +} + +func sha256Bytes(content []byte) string { + sum := sha256.Sum256(content) + + return "sha256:" + hex.EncodeToString(sum[:]) +} diff --git a/go/internal/githookcli/main.go b/go/internal/githookcli/main.go index 9091f950..5892f27b 100644 --- a/go/internal/githookcli/main.go +++ b/go/internal/githookcli/main.go @@ -19,6 +19,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/apperror" "blackcat.ca/coding-ethos/go/internal/evaluators" "blackcat.ca/coding-ethos/go/internal/feedback" + "blackcat.ca/coding-ethos/go/internal/generatedtrust" "blackcat.ca/coding-ethos/go/internal/gitwrap" "blackcat.ca/coding-ethos/go/internal/hookoutput" "blackcat.ca/coding-ethos/go/internal/hookrunnercli" @@ -493,10 +494,11 @@ func runHookPolicy( files []string, ) (lint.Result, error) { result, err := lint.Run(bundle, lint.Options{ - AdminApproved: adminApproved(cwd), - Scope: scope, - Cwd: cwd, - Files: files, + AdminApproved: adminApproved(cwd), + Scope: scope, + Cwd: cwd, + Files: files, + TrustedGeneratedFiles: generatedtrust.ExactStagedFiles(bundle, cwd, files), }) if err != nil { return lint.Result{}, fmt.Errorf("run hook policy: %w", err) diff --git a/go/internal/hooklog/runner.go b/go/internal/hooklog/runner.go index 0f0e1247..6539ffa5 100644 --- a/go/internal/hooklog/runner.go +++ b/go/internal/hooklog/runner.go @@ -167,7 +167,7 @@ func runWithStatus(options Options) (int, error) { return 1, metadataErr } - maintenanceErr := finishHookMaintenance(options, runDir) + maintenanceErr := finishHookMaintenance(options, runDir, status) return completedHookStatus(status, err, maintenanceErr) } @@ -199,8 +199,8 @@ func completedHookStatus(status int, runErr, maintenanceErr error) (int, error) return status, nil } -func finishHookMaintenance(options Options, runDir string) error { - err := refreshCodeIntelAfterRun(options, runDir) +func finishHookMaintenance(options Options, runDir string, status int) error { + err := refreshCodeIntelAfterRun(options, runDir, status) if err != nil { return err } @@ -214,8 +214,8 @@ func finishHookMaintenance(options Options, runDir string) error { return nil } -func refreshCodeIntelAfterRun(options Options, runDir string) error { - if shouldForceCodeIntelRefresh(options.Command) { +func refreshCodeIntelAfterRun(options Options, runDir string, status int) error { + if shouldRefreshRepository(options.Command, status) { _, err := codeintel.RefreshRepository( context.Background(), options.Root, @@ -247,6 +247,10 @@ func refreshCodeIntelAfterRun(options Options, runDir string) error { return nil } +func shouldRefreshRepository(command []string, status int) bool { + return status == 0 && shouldForceCodeIntelRefresh(command) +} + func autoPruneHookRuns(root string) error { err := outputsurface.AutoPruneSurface(context.Background(), root, "hook_runs", false) if err != nil { diff --git a/go/internal/hooklog/runner_internal_test.go b/go/internal/hooklog/runner_internal_test.go index 725a8f32..693eb238 100644 --- a/go/internal/hooklog/runner_internal_test.go +++ b/go/internal/hooklog/runner_internal_test.go @@ -37,3 +37,15 @@ func TestShouldForceCodeIntelRefreshKeepsHookAndRepoGates(t *testing.T) { } } } + +func TestShouldRefreshRepositorySkipsFailedHook(t *testing.T) { + t.Parallel() + + command := []string{"coding-ethos-run", "git-hook", "pre-commit"} + if !shouldRefreshRepository(command, 0) { + t.Fatal("successful pre-commit should refresh repository code-intel") + } + if shouldRefreshRepository(command, 2) { + t.Fatal("blocked pre-commit must not perform a full repository refresh") + } +} diff --git a/go/internal/lint/runner.go b/go/internal/lint/runner.go index cbb94bb7..df9a75ee 100644 --- a/go/internal/lint/runner.go +++ b/go/internal/lint/runner.go @@ -43,12 +43,13 @@ var ( ) type Options struct { - Command string - Cwd string - Scope string - Files []string - Argv []string - AdminApproved bool + Command string + Cwd string + Scope string + Files []string + Argv []string + TrustedGeneratedFiles []string + AdminApproved bool } func Run(bundle policy.Bundle, options Options) (Result, error) { @@ -70,6 +71,7 @@ func RunWithRegistry( return Result{}, err } + trustedGenerated := normalizedTrustedGeneratedFiles(options, scope) decisions := make([]policy.Decision, 0, len(policyIDs)) for _, policyID := range policyIDs { policyDef, ok := bundle.Policies[policyID] @@ -82,7 +84,15 @@ func RunWithRegistry( ) } - evaluated, err := evaluatePolicy(policyDef, scope, options, registry) + policyOptions := options + if policyAllowsExactGeneratedFiles(policyID) { + policyOptions.Files = withoutTrustedGeneratedFiles( + options.Files, + trustedGenerated, + ) + } + + evaluated, err := evaluatePolicy(policyDef, scope, policyOptions, registry) if err != nil { return Result{}, fmt.Errorf("evaluate policy %q: %w", policyID, err) } @@ -104,6 +114,51 @@ func RunWithRegistry( return EnrichResultWithSkills(result, bundle.Skills), nil } +func policyAllowsExactGeneratedFiles(policyID string) bool { + switch policyID { + case "agent_workspace.enforcement_point_write", + "filesystem.protected_path", + "shell.forbidden_strings": + return true + default: + return false + } +} + +func withoutTrustedGeneratedFiles(files []string, trusted map[string]bool) []string { + filtered := make([]string, 0, len(files)) + for _, file := range files { + if trusted[filepath.ToSlash(filepath.Clean(file))] { + continue + } + + filtered = append(filtered, file) + } + + return filtered +} + +func normalizedTrustedGeneratedFiles(options Options, scope string) map[string]bool { + trusted := map[string]bool{} + if scope != ScopeStaged { + return trusted + } + + staged := make(map[string]bool, len(options.Files)) + for _, file := range options.Files { + staged[filepath.ToSlash(filepath.Clean(file))] = true + } + + for _, file := range options.TrustedGeneratedFiles { + path := filepath.ToSlash(filepath.Clean(file)) + if staged[path] { + trusted[path] = true + } + } + + return trusted +} + func enrichDecisionDiagnostics( decisions []policy.Decision, evidenceMaps []diagnostics.EvidenceMap, diff --git a/go/internal/lint/runner_test.go b/go/internal/lint/runner_test.go index 29f73e05..a37f431d 100644 --- a/go/internal/lint/runner_test.go +++ b/go/internal/lint/runner_test.go @@ -5,13 +5,17 @@ package lint_test import ( "os" + "os/exec" "path/filepath" "strings" "testing" "blackcat.ca/coding-ethos/go/diagnostics" + "blackcat.ca/coding-ethos/go/internal/agenthooks" + "blackcat.ca/coding-ethos/go/internal/generatedtrust" . "blackcat.ca/coding-ethos/go/internal/lint" "blackcat.ca/coding-ethos/go/internal/policy" + "blackcat.ca/coding-ethos/go/internal/toolconfigs" ) const statusBlocked = "blocked" @@ -277,6 +281,125 @@ func TestRunLimitsForbiddenStringFileContentScanToAutomationSurfaces(t *testing. } } +func TestRunAllowsExactStagedGeneratedToolConfig(t *testing.T) { + t.Parallel() + + ethosRoot := repoRootForLintTest(t) + repo := initializedLintGitRepo(t) + + _, err := toolconfigs.Sync(ethosRoot, repo, "") + if err != nil { + t.Fatalf("sync generated tool configs: %v", err) + } + runLintGit(t, repo, "add", ".bandit.yml") + + bundle := compiledRepoLintBundle(t) + files := []string{".bandit.yml"} + result, err := Run(bundle, Options{ + Scope: ScopeStaged, + Cwd: repo, + Files: files, + TrustedGeneratedFiles: generatedtrust.ExactStagedFiles(bundle, repo, files), + }) + if err != nil { + t.Fatalf("run staged generated-config lint: %v", err) + } + if lintResultHasBlockingDecision(result, "filesystem.protected_path") || + lintResultHasBlockingDecision(result, "agent_workspace.enforcement_point_write") { + t.Fatalf("exact generated config was blocked: %#v", result.Decisions) + } +} + +func TestRunBlocksDivergentStagedGeneratedToolConfig(t *testing.T) { + t.Parallel() + + ethosRoot := repoRootForLintTest(t) + repo := initializedLintGitRepo(t) + + _, err := toolconfigs.Sync(ethosRoot, repo, "") + if err != nil { + t.Fatalf("sync generated tool configs: %v", err) + } + path := filepath.Join(repo, ".bandit.yml") + expected, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read generated tool config: %v", err) + } + if err = os.WriteFile(path, []byte("skips: [B101]\n"), 0o600); err != nil { + t.Fatalf("write divergent generated tool config: %v", err) + } + runLintGit(t, repo, "add", ".bandit.yml") + if err = os.WriteFile(path, expected, 0o600); err != nil { + t.Fatalf("restore generated tool config: %v", err) + } + + bundle := compiledRepoLintBundle(t) + files := []string{".bandit.yml"} + result, err := Run(bundle, Options{ + Scope: ScopeStaged, + Cwd: repo, + Files: files, + TrustedGeneratedFiles: generatedtrust.ExactStagedFiles(bundle, repo, files), + }) + if err != nil { + t.Fatalf("run divergent staged generated-config lint: %v", err) + } + if !lintResultHasBlockingDecision(result, "filesystem.protected_path") { + t.Fatalf("divergent generated config was not blocked: %#v", result.Decisions) + } +} + +func TestRunAllowsExactStagedGeneratedAgentConfig(t *testing.T) { + t.Parallel() + + ethosRoot := repoRootForLintTest(t) + repo := initializedLintGitRepo(t) + hookCommand := filepath.Join(ethosRoot, "bin", "coding-ethos-run") + " agent-hook" + + if err := agenthooks.SyncSettings(repo, hookCommand); err != nil { + t.Fatalf("sync generated agent settings: %v", err) + } + runLintGit(t, repo, "add", ".codex/config.toml") + + bundle := compiledRepoLintBundle(t) + files := []string{".codex/config.toml"} + result, err := Run(bundle, Options{ + Scope: ScopeStaged, + Cwd: repo, + Files: files, + TrustedGeneratedFiles: generatedtrust.ExactStagedFiles(bundle, repo, files), + }) + if err != nil { + t.Fatalf("run staged generated-agent lint: %v", err) + } + if lintResultHasBlockingDecision(result, "agent_workspace.enforcement_point_write") || + lintResultHasBlockingDecision(result, "shell.forbidden_strings") { + t.Fatalf("exact generated agent config was blocked: %#v", result.Decisions) + } +} + +func initializedLintGitRepo(t *testing.T) string { + t.Helper() + + repo := t.TempDir() + runLintGit(t, repo, "init") + runLintGit(t, repo, "config", "user.email", "test@example.com") + runLintGit(t, repo, "config", "user.name", "Test") + + return repo +} + +func runLintGit(t *testing.T, cwd string, args ...string) { + t.Helper() + + command := exec.Command("git", args...) + command.Dir = cwd + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } +} + func TestRunAcceptsCommitMessageScope(t *testing.T) { t.Parallel() @@ -362,6 +485,17 @@ func lintResultHasDecision(result Result, policyID string) bool { return false } +func lintResultHasBlockingDecision(result Result, policyID string) bool { + for _, decision := range result.Decisions { + if decision.PolicyID == policyID && + (decision.Decision == "block" || decision.Severity == "block") { + return true + } + } + + return false +} + func compiledRepoLintBundle(tb testing.TB) policy.Bundle { tb.Helper() diff --git a/go/internal/lintcli/main.go b/go/internal/lintcli/main.go index 1dbd9839..d2983f28 100644 --- a/go/internal/lintcli/main.go +++ b/go/internal/lintcli/main.go @@ -15,6 +15,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/codeintel" "blackcat.ca/coding-ethos/go/internal/evaluators" "blackcat.ca/coding-ethos/go/internal/feedback" + "blackcat.ca/coding-ethos/go/internal/generatedtrust" "blackcat.ca/coding-ethos/go/internal/hookoutput" "blackcat.ca/coding-ethos/go/internal/lint" "blackcat.ca/coding-ethos/go/internal/managedcapture" @@ -471,11 +472,12 @@ func runLintMode(config lintCLIConfig, bundle policy.Bundle) int { } result, err := lint.Run(bundle, lint.Options{ - Scope: config.scope.Value(), - Files: files, - Argv: parseArgv(*config.argvRaw), - Command: *config.command, - Cwd: *config.cwd, + Scope: config.scope.Value(), + Files: files, + Argv: parseArgv(*config.argvRaw), + Command: *config.command, + Cwd: *config.cwd, + TrustedGeneratedFiles: generatedtrust.ExactStagedFiles(bundle, *config.cwd, files), }) if err != nil { exitErr(err) diff --git a/go/internal/mcp/server.go b/go/internal/mcp/server.go index 4ef1e0fe..f8c06dc7 100644 --- a/go/internal/mcp/server.go +++ b/go/internal/mcp/server.go @@ -18,6 +18,7 @@ import ( "blackcat.ca/coding-ethos/go/diagnostics" "blackcat.ca/coding-ethos/go/internal/apperror" + "blackcat.ca/coding-ethos/go/internal/generatedtrust" "blackcat.ca/coding-ethos/go/internal/hookoutput" "blackcat.ca/coding-ethos/go/internal/hooks" "blackcat.ca/coding-ethos/go/internal/lint" @@ -432,11 +433,16 @@ func (server Server) checkLint(args json.RawMessage) (any, error) { } result, err := lint.Run(server.bundle, lint.Options{ - Command: input.Command, - Cwd: input.Cwd, - Scope: input.Scope, - Files: append([]string(nil), input.Files...), - Argv: append([]string(nil), input.Argv...), + Command: input.Command, + Cwd: input.Cwd, + Scope: input.Scope, + Files: append([]string(nil), input.Files...), + Argv: append([]string(nil), input.Argv...), + TrustedGeneratedFiles: generatedtrust.ExactStagedFiles( + server.bundle, + input.Cwd, + input.Files, + ), AdminApproved: input.AdminApproved, }) if err != nil { From 3810efd7c4872a24258c51f052c521730b9dcda5 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 10:42:37 -0600 Subject: [PATCH 13/30] fix(lint): satisfy full-tree layout gate --- go/internal/lint/runner.go | 1 + 1 file changed, 1 insertion(+) diff --git a/go/internal/lint/runner.go b/go/internal/lint/runner.go index df9a75ee..8a34cc61 100644 --- a/go/internal/lint/runner.go +++ b/go/internal/lint/runner.go @@ -72,6 +72,7 @@ func RunWithRegistry( } trustedGenerated := normalizedTrustedGeneratedFiles(options, scope) + decisions := make([]policy.Decision, 0, len(policyIDs)) for _, policyID := range policyIDs { policyDef, ok := bundle.Policies[policyID] From 210c6d1b0a3070f66eda66eb023a788cd068f499 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 12:15:41 -0600 Subject: [PATCH 14/30] fix(policy): allow synthetic sandbox homes --- config.yaml | 2 ++ go/internal/evaluators/file_guards.go | 38 +++++++++++++++++++++- go/internal/evaluators/file_guards_test.go | 35 ++++++++++++++++++++ go/internal/policy/compiler_policies.go | 5 +++ go/internal/policy/compiler_test.go | 9 +++++ repo_config.example.yaml | 2 ++ 6 files changed, 90 insertions(+), 1 deletion(-) diff --git a/config.yaml b/config.yaml index 805836fc..e44e876c 100644 --- a/config.yaml +++ b/config.yaml @@ -699,6 +699,8 @@ filesystem: - 'lbox-worktrees/[A-Za-z0-9._-]+' - '/tmp/tmp\.[A-Za-z0-9._-]+' literals: [] + allowed_patterns: + - '/home/agent/' exempt_prefixes: - .git/ license_header: diff --git a/go/internal/evaluators/file_guards.go b/go/internal/evaluators/file_guards.go index dd4cf051..6fecf233 100644 --- a/go/internal/evaluators/file_guards.go +++ b/go/internal/evaluators/file_guards.go @@ -251,6 +251,16 @@ func EvaluatePIIScrubber( return nil, err } + allowedPatterns, err := compiledPatterns( + context.EvaluatorOptions, + "allowed_patterns", + []string{`/home/agent/`}, + "allowed PII", + ) + if err != nil { + return nil, err + } + exemptPrefixes := stringSliceOption( context.EvaluatorOptions, "exempt_prefixes", @@ -268,8 +278,13 @@ func EvaluatePIIScrubber( found, err := scanGuardLines( path, func(lineNumber int, line string) ([]policy.Decision, bool) { + candidate := line + for _, allowed := range allowedPatterns { + candidate = allowed.ReplaceAllString(candidate, "") + } + for _, pattern := range patterns { - if pattern.MatchString(line) { + if pattern.MatchString(candidate) { return []policy.Decision{ fileGuardDecision( policyDef, @@ -483,6 +498,27 @@ func piiPatterns(options map[string]any) ([]*regexp.Regexp, error) { return patterns, nil } +func compiledPatterns( + options map[string]any, + key string, + defaults []string, + label string, +) ([]*regexp.Regexp, error) { + rawPatterns := stringSliceOption(options, key, defaults) + + patterns := make([]*regexp.Regexp, 0, len(rawPatterns)) + for _, raw := range rawPatterns { + pattern, err := regexp.Compile(raw) + if err != nil { + return nil, fmt.Errorf("compile %s pattern %q: %w", label, raw, err) + } + + patterns = append(patterns, pattern) + } + + return patterns, nil +} + func hasHiddenDirectoryComponent(path string) bool { normalized := filepath.ToSlash(path) normalized = strings.TrimPrefix(normalized, "./") diff --git a/go/internal/evaluators/file_guards_test.go b/go/internal/evaluators/file_guards_test.go index 4edbc7d9..f20a42a0 100644 --- a/go/internal/evaluators/file_guards_test.go +++ b/go/internal/evaluators/file_guards_test.go @@ -200,6 +200,41 @@ func TestEvaluatePIIScrubberBlocksConfiguredLiteral(t *testing.T) { } } +func TestEvaluatePIIScrubberAllowsSyntheticAgentHome(t *testing.T) { + t.Parallel() + + path := writeGuardTestFile(t, "sandbox.md", "PATH=/"+"home/agent/bin:/usr/bin\n") + decisions, err := EvaluatePIIScrubber( + fileGuardPolicy("repo.pii_scrubber"), + Context{Files: []string{path}}, + ) + if err != nil { + t.Fatalf("evaluate PII scrubber: %v", err) + } + if len(decisions) != 0 { + t.Fatalf("synthetic sandbox home should pass, got %#v", decisions) + } +} + +func TestEvaluatePIIScrubberStillBlocksRealHomeBesideSyntheticHome(t *testing.T) { + t.Parallel() + + path := writeGuardTestFile( + t, + "sandbox.md", + "sandbox: /"+"home/agent/bin host: /"+"home/example/private\n", + ) + decision := evaluateFileGuardPolicy( + t, + "repo.pii_scrubber", + EvaluatePIIScrubber, + Context{Files: []string{path}}, + ) + if decision.Diagnostics[0].Tool != piiToolName { + t.Fatalf("unexpected diagnostic: %#v", decision.Diagnostics) + } +} + func TestEvaluatePIIScrubberSkipsHiddenDirectories(t *testing.T) { t.Parallel() diff --git a/go/internal/policy/compiler_policies.go b/go/internal/policy/compiler_policies.go index b42a78fe..3cd76dc0 100644 --- a/go/internal/policy/compiler_policies.go +++ b/go/internal/policy/compiler_policies.go @@ -423,6 +423,11 @@ func piiScrubberOptions(config map[string]any) map[string]any { []string{"filesystem", "pii_scrubber", "literals"}, nil, ), + "allowed_patterns": stringSliceAt( + config, + []string{"filesystem", "pii_scrubber", "allowed_patterns"}, + []string{`/home/agent/`}, + ), "exempt_prefixes": stringSliceAt( config, []string{"filesystem", "pii_scrubber", "exempt_prefixes"}, diff --git a/go/internal/policy/compiler_test.go b/go/internal/policy/compiler_test.go index 21b81ac5..655f4cc1 100644 --- a/go/internal/policy/compiler_test.go +++ b/go/internal/policy/compiler_test.go @@ -1681,6 +1681,8 @@ filesystem: exempt_path_prefixes: [docs/plans/] required_ignores: paths: [.runtime/] + pii_scrubber: + allowed_patterns: ['/virtual/agent/'] shell: best_practices: require_common_for_prefixes: [bin/] @@ -1735,6 +1737,13 @@ func assertConfigBackedEvaluatorOptions(t *testing.T, bundle Bundle) { "required_ignore_paths", ".runtime/", ) + assertFirstOptionString( + t, + bundle, + "repo.pii_scrubber", + "allowed_patterns", + "/virtual/agent/", + ) assertFirstOptionString( t, bundle, diff --git a/repo_config.example.yaml b/repo_config.example.yaml index fbf9780d..a3bf0436 100644 --- a/repo_config.example.yaml +++ b/repo_config.example.yaml @@ -216,6 +216,8 @@ filesystem: - '/(home|Users)/[A-Za-z0-9._-]+/' - '/tmp/tmp\.[A-Za-z0-9._-]+' literals: [] + allowed_patterns: + - '/home/agent/' exempt_prefixes: - .git/ From 205a7e15121ba0bfc86024f7ac6d93bae2f35800 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 17:52:35 -0600 Subject: [PATCH 15/30] fix(integration): close merge gate findings --- Makefile | 6 +- README.md | 6 +- docs/HOOK_RUNTIME_BOOTSTRAP.md | 19 +- go.work.sum | 12 + go/cmd/coding-ethos-run/dispatch.go | 57 +--- go/cmd/coding-ethos-run/main_test.go | 81 ++---- go/go.mod | 8 +- go/go.sum | 16 +- .../evaluators/shell_best_practices.go | 110 +++++++- .../evaluators/shell_best_practices_test.go | 116 ++++++++- go/internal/generatedtrust/staged.go | 39 ++- go/internal/hooklog/runner.go | 6 +- go/internal/hooklog/runner_test.go | 41 +-- go/internal/hookrunnercli/external_tool.go | 210 +++++++++++++-- .../external_tool_internal_test.go | 244 ++++++++++++++++-- go/internal/hookrunnercli/python_policies.go | 5 +- go/internal/hooks/gate_exit_status.go | 41 +-- go/internal/hooks/gate_exit_status_test.go | 20 ++ go/internal/lint/runner_test.go | 27 ++ go/internal/managedcapture/capture.go | 10 +- go/internal/managedcapture/capture_process.go | 2 +- go/internal/managedcapture/capture_test.go | 72 +++++- .../managedcapture/sandbox_cache_env.go | 21 ++ .../managedcapture/sandbox_cache_env_test.go | 45 ++++ go/internal/policygitcli/main.go | 2 +- .../policygitcli/main_internal_test.go | 10 + go/internal/sandbox/sandbox.go | 23 +- .../toolprotocol/actionlint_shellcheck.go | 46 ++++ .../actionlint_shellcheck_test.go | 74 ++++++ pre-commit/hooks/pyproject.toml | 4 +- pre-commit/hooks/uv.lock | 10 +- pyproject.toml | 2 +- uv.lock | 10 +- 33 files changed, 1137 insertions(+), 258 deletions(-) create mode 100644 go/internal/managedcapture/sandbox_cache_env_test.go create mode 100644 go/internal/toolprotocol/actionlint_shellcheck.go create mode 100644 go/internal/toolprotocol/actionlint_shellcheck_test.go diff --git a/Makefile b/Makefile index 75634d51..27b0c6fc 100644 --- a/Makefile +++ b/Makefile @@ -35,8 +35,12 @@ GOFMT ?= gofmt CARGO ?= cargo GO_BUILD_FLAGS ?= -trimpath -buildvcs=false GO_BUILD_CACHE_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/go-build +GO_PATH_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/go-path +GO_MODULE_CACHE_DIR ?= $(GO_PATH_DIR)/pkg/mod UV_CACHE_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/uv export GOCACHE := $(GO_BUILD_CACHE_DIR) +export GOPATH := $(GO_PATH_DIR) +export GOMODCACHE := $(GO_MODULE_CACHE_DIR) export UV_CACHE_DIR empty := @@ -678,7 +682,7 @@ _sync-parent-hook-runtime: ensure-go go-tools-install policy-bundle-install @"$(GO_TOOLS_BIN_DIR)/coding-ethos-toolchain" install-git-shim \ --dest-dir "$(PARENT_HOOK_BIN_DIR)" \ --real-git "$(GIT)" \ - --runner "$(GO_HOOK)" + --runner "$(PARENT_HOOK_BIN_DIR)/coding-ethos-run" @"$(GO_TOOLS_BIN_DIR)/coding-ethos-lint" \ --install-shims \ --tools-bin-dir "$(PARENT_HOOK_BIN_DIR)" \ diff --git a/README.md b/README.md index 0ed61ef2..1207904a 100644 --- a/README.md +++ b/README.md @@ -945,8 +945,10 @@ whole-repository refresh, because no accepted source transition occurred. Consumer commits may include generated tool and provider configuration only when the staged Git-index bytes exactly match output rendered by the active -Coding Ethos authority. That narrow trust record exempts the generated surface -from path-write guards while every content policy still runs; restoring a clean +Coding Ethos authority and the path is present in the staged diff. That narrow +trust record exempts the generated surface from path-write guards and the +forbidden-string scan that would otherwise reject the authority's own generated +runtime commands; every other content policy still runs. Restoring a clean working-tree copy cannot conceal divergent staged bytes. When `parent-install` or `parent-lint` receives an external `--state-root`, it diff --git a/docs/HOOK_RUNTIME_BOOTSTRAP.md b/docs/HOOK_RUNTIME_BOOTSTRAP.md index a1460776..67459b86 100644 --- a/docs/HOOK_RUNTIME_BOOTSTRAP.md +++ b/docs/HOOK_RUNTIME_BOOTSTRAP.md @@ -219,11 +219,20 @@ Bootstrap needs a few guardrails: - Keep authority build outputs under ignored `bin/` and `build/` directories. - Keep response, trace, and other transient repo-local caches under ignored `.coding-ethos/` paths, not under the Git common runtime. -- Hook-launched `uv` commands bind both their download cache and project - environment to the consumer-owned `.coding-ethos/cache/` tree and use the - sealed project's committed lockfile in frozen mode. The installed shared - runtime remains read-only and never receives a generated `.venv` or a lockfile - rewrite. +- Hook and managed-capture Go commands use the consumer-owned + `.coding-ethos/cache/go-path/` as `GOPATH` and its `pkg/mod/` child as + `GOMODCACHE`. This keeps downloaded modules and Go's `pkg/sumdb` checksum + database writable inside the admitted repository cache instead of inheriting + an operator-owned host path. +- Hook-launched `uv` commands share the consumer-owned + `.coding-ethos/cache/uv/` download cache. Each active project, resolved from + an explicit `--project` path or the command working directory, receives a + distinct environment under `.coding-ethos/cache/uv-project-env/`. + `UV_PROJECT_ENVIRONMENT` and `UV_FROZEN` are scoped to that child invocation, + not exported across the hook process. Frozen mode is enabled only when the + command requests `--frozen` or the active project's `uv.lock` exists in + `HEAD`; unsealed projects remain writable. The installed shared runtime + remains read-only and never receives a generated `.venv` or lockfile rewrite. - Install common-runtime executables with temporary-file sync plus atomic rename, and verify them by content rather than mtime. - Keep installed hook entrypoints stable and move versioned behavior into the diff --git a/go.work.sum b/go.work.sum index ccd2ad95..0d7a58c0 100644 --- a/go.work.sum +++ b/go.work.sum @@ -11,6 +11,7 @@ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/renameio/v2 v2.0.2/go.mod h1:OX+G6WHHpHq3NVj7cAOleLOwJfcQ1s3uUJQCrr78SWo= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/hamba/avro/v2 v2.30.0/go.mod h1:X6gDhYv6DQVAT56VqOKuW+PLnQrEQqGB9l1nhlMdAdQ= @@ -35,18 +36,29 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index fc423463..58b85b57 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -33,6 +33,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/realgit" "blackcat.ca/coding-ethos/go/internal/shellparse" "blackcat.ca/coding-ethos/go/internal/shellquote" + "blackcat.ca/coding-ethos/go/internal/toolprotocol" "blackcat.ca/coding-ethos/go/toolcatalog" ) @@ -1007,13 +1008,17 @@ func runAgentHooksCommand(paths runtimePaths, rest []string) { } func runPolicyTool(paths runtimePaths, rest []string) error { - return runPolicyToolForParent(paths, rest, parentExecutablePath()) + return runPolicyToolForProtocol( + paths, + rest, + os.Getenv(toolprotocol.ActionlintShellcheckEnv), + ) } -func runPolicyToolForParent( +func runPolicyToolForProtocol( paths runtimePaths, rest []string, - parentExecutable string, + protocolMarker string, ) error { if len(rest) == 0 { return apperror.StaticError("policy-tool requires a tool name") @@ -1021,7 +1026,11 @@ func runPolicyToolForParent( requirePolicyBundle(paths) - if actionlintShellcheckDependency(parentExecutable, rest[0], rest[1:]) { + if toolprotocol.IsActionlintShellcheckJSONStdin( + protocolMarker, + rest[0], + rest[1:], + ) { tool, found := toolcatalog.HookOwnedTool("shellcheck") if !found { return apperror.StaticError("managed shellcheck tool is not registered") @@ -1039,46 +1048,6 @@ func runPolicyToolForParent( return nil } -func parentExecutablePath() string { - if runtime.GOOS != linuxGOOS { - return "" - } - - path, err := os.Readlink("/proc/" + strconv.Itoa(os.Getppid()) + "/exe") - if err != nil { - return "" - } - - return path -} - -func actionlintShellcheckDependency( - parentExecutable string, - tool string, - args []string, -) bool { - if filepath.Base(parentExecutable) != "actionlint" || - tool != "shellcheck" || - len(args) == 0 || - args[len(args)-1] != "-" { - return false - } - - for index, arg := range args { - if (arg == "-f" || arg == "--format") && - index+1 < len(args) && - args[index+1] == "json" { - return true - } - - if arg == "-f=json" || arg == "--format=json" { - return true - } - } - - return false -} - func runMCP(paths runtimePaths, rest []string) { bundlePath := hookPolicyBundlePath(paths) requireRuntimeFile(bundlePath, "compiled policy bundle") diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index f5d43aa8..6fb3f717 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -27,6 +27,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/sharedlock" "blackcat.ca/coding-ethos/go/internal/shellquote" "blackcat.ca/coding-ethos/go/internal/testlock" + "blackcat.ca/coding-ethos/go/internal/toolprotocol" "blackcat.ca/coding-ethos/go/toolcatalog" ) @@ -2610,7 +2611,9 @@ func TestPolicyGitIgnoresArbitraryEnvRealGitExecutable(t *testing.T) { } } -func TestPolicyToolExecutesActionlintShellcheckDependencyRaw(t *testing.T) { +func TestPolicyToolUsesMarkedActionlintShellcheckProtocolWithoutParentPath( + t *testing.T, +) { paths := runtimeTestPaths(t) var calls []string paths.Executor = stubRuntimeOps{calls: &calls} @@ -2634,7 +2637,11 @@ func TestPolicyToolExecutesActionlintShellcheckDependencyRaw(t *testing.T) { "--shell", "bash", "-", } - err := runPolicyToolForParent(paths, args, "/managed/bin/actionlint") + err := runPolicyToolForProtocol( + paths, + args, + toolprotocol.ActionlintShellcheckJSONStdinV1, + ) if err != nil { t.Fatalf("run actionlint shellcheck dependency: %v", err) } @@ -2645,15 +2652,15 @@ func TestPolicyToolExecutesActionlintShellcheckDependencyRaw(t *testing.T) { } } -func TestPolicyToolCapturesShellcheckOutsideActionlint(t *testing.T) { +func TestPolicyToolCapturesShellcheckWithoutActionlintMarker(t *testing.T) { paths := runtimeTestPaths(t) var calls []string paths.Executor = stubRuntimeOps{calls: &calls} - err := runPolicyToolForParent( + err := runPolicyToolForProtocol( paths, []string{"shellcheck", "-f", "json", "-"}, - "/usr/bin/bash", + "", ) if err != nil { t.Fatalf("run direct shellcheck: %v", err) @@ -2665,63 +2672,6 @@ func TestPolicyToolCapturesShellcheckOutsideActionlint(t *testing.T) { } } -func TestActionlintShellcheckDependencyRequiresJSONStdinContract(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - parent string - tool string - args []string - want bool - }{ - { - name: "actionlint json stdin", - parent: "/managed/bin/actionlint", - tool: "shellcheck", - args: []string{"--norc", "-f", "json", "-"}, - want: true, - }, - { - name: "long json format", - parent: "/managed/bin/actionlint", - tool: "shellcheck", - args: []string{"--format=json", "-"}, - want: true, - }, - { - name: "wrong parent", - parent: "/usr/bin/bash", - tool: "shellcheck", - args: []string{"-f", "json", "-"}, - }, - { - name: "not stdin", - parent: "/managed/bin/actionlint", - tool: "shellcheck", - args: []string{"-f", "json", "script.sh"}, - }, - { - name: "not json", - parent: "/managed/bin/actionlint", - tool: "shellcheck", - args: []string{"-f", "gcc", "-"}, - }, - } - - for _, test := range tests { - test := test - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - got := actionlintShellcheckDependency(test.parent, test.tool, test.args) - if got != test.want { - t.Fatalf("actionlintShellcheckDependency() = %v, want %v", got, test.want) - } - }) - } -} - func TestAgentShellNativeGitBindRequiresReadOnlyMountInfo(t *testing.T) { t.Parallel() @@ -3511,6 +3461,9 @@ func TestMakefileRoutesParentGitHooksThroughStableCommonRuntime(t *testing.T) { `$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(GO_HOOK))`, `$(call install_git_hooks,$(HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run)`, `_sync-git-hooks: ensure-go go-tools-install _sync-parent-hook-runtime`, + "--dest-dir \"$(PARENT_HOOK_BIN_DIR)\" \\\n" + + "\t\t--real-git \"$(GIT)\" \\\n" + + "\t\t--runner \"$(PARENT_HOOK_BIN_DIR)/coding-ethos-run\"", } { if !strings.Contains(makefile, want) { t.Fatalf("Makefile missing stable Git hook route %q", want) @@ -3523,6 +3476,10 @@ func TestMakefileRoutesParentGitHooksThroughStableCommonRuntime(t *testing.T) { ) { t.Fatal("Makefile routes parent Git hooks through a worktree-local runner") } + + if strings.Contains(makefile, `--runner "$(GO_HOOK)"`) { + t.Fatal("Makefile routes a parent shim through a worktree-local runner") + } } func fakeCIGit(t *testing.T, diffOutput string) string { diff --git a/go/go.mod b/go/go.mod index 4563a511..dfd27d8d 100644 --- a/go/go.mod +++ b/go/go.mod @@ -22,7 +22,7 @@ require ( github.com/tree-sitter/tree-sitter-typescript v0.23.2 github.com/yuin/goldmark v1.8.2 go.uber.org/zap v1.28.0 - golang.org/x/sys v0.46.0 + golang.org/x/sys v0.47.0 mvdan.cc/sh/v3 v3.13.1 ) @@ -43,10 +43,10 @@ require ( github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/mod v0.37.0 // indirect + golang.org/x/mod v0.40.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect + golang.org/x/tools v0.49.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect ) diff --git a/go/go.sum b/go/go.sum index b4bac55e..d6ca0b16 100644 --- a/go/go.sum +++ b/go/go.sum @@ -118,18 +118,18 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/go/internal/evaluators/shell_best_practices.go b/go/internal/evaluators/shell_best_practices.go index f6f6c3e5..9a53381a 100644 --- a/go/internal/evaluators/shell_best_practices.go +++ b/go/internal/evaluators/shell_best_practices.go @@ -20,7 +20,11 @@ import ( ) var ( - shellStrictModePattern = regexp.MustCompile( + errShellHelperWorkingDirectoryRequired = errors.New( + "inspect tracked common shell helpers: repository working directory is required", + ) + errShellHelperPrefixOutsideRepository = errors.New("is outside the repository") + shellStrictModePattern = regexp.MustCompile( `(?m)^\s*set\s+-[euo]+\s*pipefail|^\s*set\s+-euo\s+pipefail`, ) shellCommonSourcePattern = regexp.MustCompile( @@ -38,12 +42,25 @@ func EvaluateShellBestPractices( policyDef policy.Policy, context Context, ) ([]policy.Decision, error) { + if len(context.Files) == 0 { + return nil, nil + } + requireCommon := stringSliceOption( context.EvaluatorOptions, "require_common_for_prefixes", []string{"scripts/"}, ) - if !repositoryHasTrackedCommonShellHelper(context.Cwd) { + + hasCommonHelper, err := repositoryHasTrackedCommonShellHelper( + context.Cwd, + requireCommon, + ) + if err != nil { + return nil, err + } + + if !hasCommonHelper { requireCommon = nil } @@ -72,23 +89,98 @@ func EvaluateShellBestPractices( return nil, nil } -func repositoryHasTrackedCommonShellHelper(cwd string) bool { +func repositoryHasTrackedCommonShellHelper( + cwd string, + requireCommonForPrefixes []string, +) (bool, error) { if strings.TrimSpace(cwd) == "" { - return false + return false, errShellHelperWorkingDirectoryRequired } - output, err := GitCommand(cwd, "ls-files", "--cached").Output() + helperPaths, err := configuredCommonShellHelperPaths( + cwd, + requireCommonForPrefixes, + ) if err != nil { - return false + return false, err + } + + if len(helperPaths) == 0 { + return false, nil + } + + args := []string{"ls-files", "--cached", "--"} + for _, helperPath := range helperPaths { + args = append(args, ":(literal)"+helperPath) + } + + output, err := GitCommand(cwd, args...).CombinedOutput() + if err != nil { + return false, fmt.Errorf( + "inspect tracked common shell helpers with git ls-files: %w: %s", + err, + strings.TrimSpace(string(output)), + ) } + configuredHelpers := stringSet(helperPaths) + for line := range strings.SplitSeq(string(output), "\n") { - if filepath.Base(strings.TrimSpace(line)) == "common.sh" { - return true + trackedPath := filepath.ToSlash(strings.TrimSpace(line)) + if configuredHelpers[trackedPath] { + return true, nil } } - return false + return false, nil +} + +func configuredCommonShellHelperPaths( + cwd string, + prefixes []string, +) ([]string, error) { + repositoryRoot, err := filepath.Abs(cwd) + if err != nil { + return nil, fmt.Errorf("resolve repository working directory: %w", err) + } + + helperPaths := make([]string, 0, len(prefixes)) + for _, prefix := range prefixes { + trimmed := strings.TrimSpace(prefix) + if trimmed == "" { + continue + } + + cleaned := filepath.Clean(trimmed) + if filepath.IsAbs(cleaned) { + cleaned, err = filepath.Rel(repositoryRoot, cleaned) + if err != nil { + return nil, fmt.Errorf( + "resolve common shell helper prefix %q: %w", + prefix, + err, + ) + } + } + + if cleaned == ".." || strings.HasPrefix( + cleaned, + ".."+string(filepath.Separator), + ) { + return nil, fmt.Errorf( + "common shell helper prefix %q %w", + prefix, + errShellHelperPrefixOutsideRepository, + ) + } + + helperPaths = append( + helperPaths, + filepath.ToSlash(filepath.Join(cleaned, "common.sh")), + ) + } + + return helperPaths, nil } func looksLikeShellFile(path string) bool { diff --git a/go/internal/evaluators/shell_best_practices_test.go b/go/internal/evaluators/shell_best_practices_test.go index 3ebab1a4..10368c21 100644 --- a/go/internal/evaluators/shell_best_practices_test.go +++ b/go/internal/evaluators/shell_best_practices_test.go @@ -6,6 +6,7 @@ package evaluators_test import ( "os" "path/filepath" + "strings" "testing" "blackcat.ca/coding-ethos/go/diagnostics" @@ -17,6 +18,7 @@ func TestEvaluateShellBestPracticesBlocksMissingStrictMode(t *testing.T) { t.Parallel() dir := t.TempDir() + initializeStagedAdminGitRepo(t, dir) path := filepath.Join(dir, "script.sh") @@ -31,7 +33,7 @@ func TestEvaluateShellBestPracticesBlocksMissingStrictMode(t *testing.T) { decisions, err := EvaluateShellBestPractices( shellBestPracticesPolicy(), - Context{Files: []string{path}}, + Context{Cwd: dir, Files: []string{path}}, ) if err != nil { t.Fatalf("evaluate shell best practices: %v", err) @@ -50,6 +52,7 @@ func TestEvaluateShellBestPracticesBlocksInvalidShellSyntaxWithLocation(t *testi t.Parallel() dir := t.TempDir() + initializeStagedAdminGitRepo(t, dir) path := filepath.Join(dir, "script.sh") content := "#!/usr/bin/env bash\nset -euo pipefail\necho 'unterminated\n" @@ -61,7 +64,7 @@ func TestEvaluateShellBestPracticesBlocksInvalidShellSyntaxWithLocation(t *testi decisions, err := EvaluateShellBestPractices( shellBestPracticesPolicy(), - Context{Files: []string{path}}, + Context{Cwd: dir, Files: []string{path}}, ) if err != nil { t.Fatalf("evaluate shell best practices: %v", err) @@ -88,6 +91,115 @@ func TestEvaluateShellBestPracticesBlocksInvalidShellSyntaxWithLocation(t *testi } } +func TestEvaluateShellBestPracticesRejectsMissingRepositoryContext(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "script.sh") + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + _, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{Files: []string{path}}, + ) + if err == nil { + t.Fatal("missing repository context must fail helper validation") + } + if !strings.Contains(err.Error(), "repository working directory is required") { + t.Fatalf("missing repository context error = %q", err) + } +} + +func TestEvaluateShellBestPracticesPropagatesGitInspectionFailure(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + scriptsDir := filepath.Join(repo, "scripts") + if err := os.MkdirAll(scriptsDir, 0o700); err != nil { + t.Fatalf("create scripts directory: %v", err) + } + + scriptPath := filepath.Join(scriptsDir, "work.sh") + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + if err := os.WriteFile(scriptPath, content, 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + if err := os.WriteFile( + filepath.Join(repo, ".git"), + []byte("gitdir: missing\n"), + 0o600, + ); err != nil { + t.Fatalf("write invalid Git directory marker: %v", err) + } + + _, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{ + Cwd: repo, + Files: []string{scriptPath}, + EvaluatorOptions: map[string]any{ + "require_common_for_prefixes": []any{ + scriptsDir + string(filepath.Separator), + }, + }, + }, + ) + if err == nil { + t.Fatal("git inspection failure must fail helper validation") + } + if !strings.Contains(err.Error(), "git ls-files") { + t.Fatalf("git inspection error = %q", err) + } +} + +func TestEvaluateShellBestPracticesIgnoresUnrelatedCommonHelper(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + initializeStagedAdminGitRepo(t, repo) + scriptsDir := filepath.Join(repo, "scripts") + fixturesDir := filepath.Join(repo, "fixtures") + if err := os.MkdirAll(scriptsDir, 0o700); err != nil { + t.Fatalf("create scripts directory: %v", err) + } + if err := os.MkdirAll(fixturesDir, 0o700); err != nil { + t.Fatalf("create fixtures directory: %v", err) + } + + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + scriptPath := filepath.Join(scriptsDir, "work.sh") + if err := os.WriteFile(scriptPath, content, 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + fixturePath := filepath.Join(fixturesDir, "common.sh") + if err := os.WriteFile(fixturePath, content, 0o600); err != nil { + t.Fatalf("write unrelated common helper: %v", err) + } + runGit(t, repo, "add", "fixtures/common.sh") + + decisions, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{ + Cwd: repo, + Files: []string{scriptPath}, + EvaluatorOptions: map[string]any{ + "require_common_for_prefixes": []any{ + scriptsDir + string(filepath.Separator), + }, + }, + }, + ) + if err != nil { + t.Fatalf("evaluate with unrelated common helper: %v", err) + } + if len(decisions) != 0 { + t.Fatalf("unrelated common helper must not activate convention: %#v", decisions) + } +} + func TestEvaluateShellBestPracticesRequiresOnlyExistingCommonHelper(t *testing.T) { t.Parallel() diff --git a/go/internal/generatedtrust/staged.go b/go/internal/generatedtrust/staged.go index 6972ae57..7a69ea13 100644 --- a/go/internal/generatedtrust/staged.go +++ b/go/internal/generatedtrust/staged.go @@ -6,6 +6,7 @@ package generatedtrust import ( + "bytes" "crypto/sha256" "encoding/hex" "path/filepath" @@ -34,12 +35,18 @@ func ExactStagedFiles(bundle policy.Bundle, cwd string, files []string) []string } artifacts := generatedArtifacts(ethosRoot, cwd) - staged := normalizedPathSet(files) + requested := normalizedPathSet(files) + + staged := stagedChangedPathSet(cwd) + if staged == nil { + return nil + } + trusted := map[string]bool{} for _, artifact := range artifacts { path := filepath.ToSlash(filepath.Clean(artifact.Path)) - if !staged[path] || artifact.ExpectedSHA256 == "" { + if !requested[path] || !staged[path] || artifact.ExpectedSHA256 == "" { continue } @@ -59,6 +66,34 @@ func ExactStagedFiles(bundle policy.Bundle, cwd string, files []string) []string return result } +func stagedChangedPathSet(cwd string) map[string]bool { + content, err := evaluators.GitCommand( + cwd, + "diff", + "--cached", + "--name-only", + "--diff-filter=ACMR", + "-z", + "--", + ).Output() + if err != nil { + return nil + } + + paths := map[string]bool{} + + for rawPath := range bytes.SplitSeq(content, []byte{0}) { + path := string(rawPath) + if path == "" { + continue + } + + paths[filepath.ToSlash(filepath.Clean(path))] = true + } + + return paths +} + func generatedArtifacts(ethosRoot, repoRoot string) []syncstate.Artifact { artifacts := []syncstate.Artifact{} diff --git a/go/internal/hooklog/runner.go b/go/internal/hooklog/runner.go index 6539ffa5..5b6f70fb 100644 --- a/go/internal/hooklog/runner.go +++ b/go/internal/hooklog/runner.go @@ -261,9 +261,9 @@ func autoPruneHookRuns(root string) error { } func shouldForceCodeIntelRefresh(command []string) bool { - // Parent workflows refresh their explicit --repo target as part of the - // workflow itself. Repeating that work here both doubles maintenance and, - // before command-root resolution, could index the invocation repository. + // Hooklog owns refreshes only for policy-lint, Git hooks, and the selected + // Make targets below. Parent-install, parent-check, and parent-lint do not + // refresh code intelligence and must not be described as doing so here. return commandContains(command, "policy-lint") || commandContainsSequence(command, "git-hook", "pre-commit") || commandContainsSequence(command, "git-hook", "pre-push") || diff --git a/go/internal/hooklog/runner_test.go b/go/internal/hooklog/runner_test.go index 28468aa4..713bd9bb 100644 --- a/go/internal/hooklog/runner_test.go +++ b/go/internal/hooklog/runner_test.go @@ -5,6 +5,7 @@ package hooklog_test import ( "bytes" + "context" "errors" "os" "path/filepath" @@ -14,6 +15,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/codeintel" . "blackcat.ca/coding-ethos/go/internal/hooklog" + "blackcat.ca/coding-ethos/go/internal/realgit" "blackcat.ca/coding-ethos/go/internal/testlock" ) @@ -174,14 +176,19 @@ func TestRunWritesDebugLogAndStderrWhenEnabled(t *testing.T) { } func TestRunChecksIgnoresWithoutIndex(t *testing.T) { - t.Parallel() + testlock.ProcessState(t, "hooklog-git-trace") root := t.TempDir() writeHookLogIgnore(t, root) logPath := filepath.Join(t.TempDir(), "git-args.log") - git := fakeGitWithLog(t, logPath) + t.Setenv("GIT_TRACE", logPath) - err := Run(Options{ + git, err := realgit.Resolve(context.Background(), "git") + if err != nil { + t.Fatalf("resolve real git: %v", err) + } + + err = Run(Options{ Stdin: strings.NewReader(""), Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, @@ -524,7 +531,7 @@ func fakeGit(t *testing.T) string { dir := t.TempDir() path := filepath.Join(dir, "git") - script := fakeGitCheckIgnoreScript("") + script := fakeGitCheckIgnoreScript() writeExecutableTestFile(t, path, script) @@ -557,31 +564,9 @@ func fakeFailingGit(t *testing.T) string { return path } -func fakeGitWithLog(t *testing.T, logPath string) string { - t.Helper() - - dir := t.TempDir() - path := filepath.Join(dir, "git") - - script := fakeGitCheckIgnoreScript(logPath) - - writeExecutableTestFile(t, path, script) - - return path -} - -func shellQuoteForTest(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" -} - -func fakeGitCheckIgnoreScript(logPath string) string { - logLine := "" - if logPath != "" { - logLine = "printf '%s\\n' \"$*\" >> " + shellQuoteForTest(logPath) + "\n" - } - +func fakeGitCheckIgnoreScript() string { return `#!/usr/bin/env bash -` + logLine + `root="" +root="" target="" while [ "$#" -gt 0 ]; do if [ "$1" = "-C" ]; then diff --git a/go/internal/hookrunnercli/external_tool.go b/go/internal/hookrunnercli/external_tool.go index 1588306f..01c3ebad 100644 --- a/go/internal/hookrunnercli/external_tool.go +++ b/go/internal/hookrunnercli/external_tool.go @@ -6,6 +6,8 @@ package hookrunnercli import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "os" @@ -70,7 +72,7 @@ func runExternalTool(request externalToolRequest) externalToolResult { ) defer cancel() - env, envErr := externalToolEnv(request.Env) + env, envErr := externalToolEnv(request) if envErr != nil { return externalToolResult{ ExitCode: 1, @@ -179,11 +181,18 @@ func externalToolCombinedOutput(stdout, stderr string) string { } } -func externalToolEnv(extra []string) ([]string, error) { - env := make([]string, 0, len(os.Environ())+len(extra)) +func externalToolEnv(request externalToolRequest) ([]string, error) { + env := make([]string, 0, len(os.Environ())+len(request.Env)) hasPath := false - cacheEnv, err := externalToolCacheEnv(repoRoot()) + root := repoRoot() + + cacheEnv, err := externalToolCacheEnv(root) + if err != nil { + return nil, err + } + + uvEnv, err := externalToolUVEnv(root, request.Command, request.Dir) if err != nil { return nil, err } @@ -202,7 +211,7 @@ func externalToolEnv(extra []string) ([]string, error) { continue } - if found && cacheEnv.overrides(name) { + if found && (cacheEnv.overrides(name) || externalToolUVScopeName(name)) { continue } @@ -218,17 +227,18 @@ func externalToolEnv(extra []string) ([]string, error) { env = append(env, "GIT_CONFIG_GLOBAL="+os.DevNull) env = append(env, "XDG_CONFIG_HOME="+os.DevNull) env = append(env, cacheEnv.items()...) + env = append(env, uvEnv...) - return append(env, extra...), nil + return append(env, request.Env...), nil } type externalToolCacheEnvironment struct { GoTemp string GoCache string + GoPath string + GoModCache string GolangCILintDir string UVCache string - UVProjectEnv string - UVFrozen string // CargoTarget is per-repository build output, like GoCache. CargoTarget string // CargoHome and RustupHome are the operator's, not the repository's. Cargo @@ -291,17 +301,19 @@ func externalToolCacheEnv(root string) (externalToolCacheEnvironment, error) { goTemp := filepath.Join(root, ".coding-ethos", "cache", "go-tmp") goCache := filepath.Join(root, sandbox.SandboxGoCachePath) + goPath := filepath.Join(root, sandbox.SandboxGoPath) + goModCache := filepath.Join(root, sandbox.SandboxGoModCachePath) golangCILintDir := filepath.Join(root, sandbox.SandboxGolangCIPath) uvCache := filepath.Join(root, ".coding-ethos", "cache", "uv") - uvProjectEnv := filepath.Join(root, ".coding-ethos", "cache", "uv-project-env") cargoTarget := filepath.Join(root, ".coding-ethos", "cache", "cargo-target") for _, dir := range []string{ goTemp, goCache, + goPath, + goModCache, golangCILintDir, uvCache, - uvProjectEnv, cargoTarget, } { err := os.MkdirAll(dir, externalToolCacheDirMode) @@ -319,10 +331,10 @@ func externalToolCacheEnv(root string) (externalToolCacheEnvironment, error) { return externalToolCacheEnvironment{ GoTemp: goTemp, GoCache: goCache, + GoPath: goPath, + GoModCache: goModCache, GolangCILintDir: golangCILintDir, UVCache: uvCache, - UVProjectEnv: uvProjectEnv, - UVFrozen: "1", CargoTarget: cargoTarget, CargoHome: cargoHome, RustupHome: rustupHome, @@ -331,10 +343,13 @@ func externalToolCacheEnv(root string) (externalToolCacheEnvironment, error) { // prepareHookProcessCacheEnvironment projects the consumer-owned cache roots // onto the hook runner itself. Nested pre-commit languages can start before an -// individual external-tool request is constructed; setting these variables at -// the command boundary prevents uv, Go, Cargo, and linters from falling back -// to an unwritable operator cache. The returned closure restores the exact -// caller environment for in-process tests and embedded invocations. +// individual external-tool request is constructed; setting universal cache +// variables at the command boundary prevents uv, Go, Cargo, and linters from +// falling back to an unwritable operator cache. GOPATH also keeps Go's checksum +// database inside the consumer cache; GOMODCACHE is its pkg/mod child. UV +// project environments and frozen mode remain scoped to individual uv child +// invocations. The returned closure restores the exact caller environment for +// in-process tests and embedded invocations. func prepareHookProcessCacheEnvironment(root string) (func(), error) { environment, err := externalToolCacheEnv(root) if err != nil { @@ -393,10 +408,10 @@ func (environment externalToolCacheEnvironment) items() []string { "TMPDIR", "GOTMPDIR", "GOCACHE", + "GOPATH", + "GOMODCACHE", "GOLANGCI_LINT_CACHE", "UV_CACHE_DIR", - "UV_PROJECT_ENVIRONMENT", - "UV_FROZEN", "CARGO_TARGET_DIR", "CARGO_HOME", "RUSTUP_HOME", @@ -418,14 +433,14 @@ func (environment externalToolCacheEnvironment) value(name string) string { return environment.GoTemp case "GOCACHE": return environment.GoCache + case "GOPATH": + return environment.GoPath + case "GOMODCACHE": + return environment.GoModCache case "GOLANGCI_LINT_CACHE": return environment.GolangCILintDir case "UV_CACHE_DIR": return environment.UVCache - case "UV_PROJECT_ENVIRONMENT": - return environment.UVProjectEnv - case "UV_FROZEN": - return environment.UVFrozen case "CARGO_TARGET_DIR": return environment.CargoTarget case "CARGO_HOME": @@ -437,6 +452,157 @@ func (environment externalToolCacheEnvironment) value(name string) string { } } +func externalToolUVEnv(root string, command []string, dir string) ([]string, error) { + project, active, err := activeUVProject(command, dir) + if err != nil || !active || strings.TrimSpace(root) == "" || root == "." { + return nil, err + } + + digest := sha256.Sum256([]byte(project)) + + projectEnv := filepath.Join( + root, + ".coding-ethos", + "cache", + "uv-project-env", + hex.EncodeToString(digest[:]), + ) + + err = os.MkdirAll(projectEnv, externalToolCacheDirMode) + if err != nil { + return nil, fmt.Errorf( + "create uv project environment %s: %w", + projectEnv, + err, + ) + } + + env := []string{"UV_PROJECT_ENVIRONMENT=" + projectEnv} + if uvInvocationRequestsFrozen(command) || uvProjectHasCommittedLock(project) { + env = append(env, "UV_FROZEN=1") + } + + return env, nil +} + +func activeUVProject(command []string, dir string) (string, bool, error) { + if len(command) == 0 || filepath.Base(command[0]) != "uv" || + slices.Contains(command[1:], "--no-project") { + return "", false, nil + } + + workingDir := strings.TrimSpace(dir) + if workingDir == "" { + var err error + + workingDir, err = os.Getwd() + if err != nil { + return "", false, fmt.Errorf("resolve uv command directory: %w", err) + } + } + + project := workingDir + + explicitProject, explicit := uvProjectArgument(command) + if explicit { + project = explicitProject + if !filepath.IsAbs(project) { + project = filepath.Join(workingDir, project) + } + } + + project, err := canonicalUVProjectPath(project) + if err != nil { + return "", false, err + } + + if !explicit { + project = discoverUVProjectRoot(project) + } + + return project, true, nil +} + +func uvProjectArgument(command []string) (string, bool) { + for index := 1; index < len(command); index++ { + if command[index] == "--project" && index+1 < len(command) { + return command[index+1], true + } + + value, found := strings.CutPrefix(command[index], "--project=") + if found && value != "" { + return value, true + } + } + + return "", false +} + +func canonicalUVProjectPath(project string) (string, error) { + absolute, err := filepath.Abs(project) + if err != nil { + return "", fmt.Errorf("resolve uv project path %s: %w", project, err) + } + + resolved, err := filepath.EvalSymlinks(absolute) + if err == nil { + return resolved, nil + } + + if !os.IsNotExist(err) { + return "", fmt.Errorf("resolve uv project symlinks %s: %w", absolute, err) + } + + return filepath.Clean(absolute), nil +} + +func discoverUVProjectRoot(start string) string { + for current := start; ; current = filepath.Dir(current) { + info, err := os.Stat(filepath.Join(current, "pyproject.toml")) + if err == nil && !info.IsDir() { + return current + } + + parent := filepath.Dir(current) + if parent == current { + return start + } + } +} + +func uvInvocationRequestsFrozen(command []string) bool { + return slices.Contains(command[1:], "--frozen") +} + +func uvProjectHasCommittedLock(project string) bool { + output, err := combinedGitOutputInRoot(project, "rev-parse", "--show-toplevel") + if err != nil { + return false + } + + gitRoot := strings.TrimSpace(string(output)) + lockPath := filepath.Join(project, "uv.lock") + + relative, err := filepath.Rel(gitRoot, lockPath) + if err != nil || relative == ".." || + strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + return false + } + + _, err = combinedGitOutputInRoot( + gitRoot, + "cat-file", + "-e", + "HEAD:"+filepath.ToSlash(relative), + ) + + return err == nil +} + +func externalToolUVScopeName(name string) bool { + return name == "UV_PROJECT_ENVIRONMENT" || name == "UV_FROZEN" +} + func externalToolPathWithoutGitShim(pathValue string) string { kept := []string{} diff --git a/go/internal/hookrunnercli/external_tool_internal_test.go b/go/internal/hookrunnercli/external_tool_internal_test.go index 6d1a01eb..636b595d 100644 --- a/go/internal/hookrunnercli/external_tool_internal_test.go +++ b/go/internal/hookrunnercli/external_tool_internal_test.go @@ -14,10 +14,12 @@ import ( "blackcat.ca/coding-ethos/go/internal/testlock" ) -func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVEnvironment(t *testing.T) { +func TestPrepareHookProcessCacheEnvironmentScopesUniversalCaches(t *testing.T) { testlock.ProcessState(t, "hook-process-cache-environment") root := t.TempDir() + t.Setenv("GOPATH", "/previous/go-path") + t.Setenv("GOMODCACHE", "/previous/go-mod-cache") t.Setenv("UV_CACHE_DIR", "/previous/uv-cache") t.Setenv("UV_PROJECT_ENVIRONMENT", "/previous/uv-project-environment") t.Setenv("UV_FROZEN", "0") @@ -33,19 +35,40 @@ func TestPrepareHookProcessCacheEnvironmentSetsAndRestoresUVEnvironment(t *testi if info, statErr := os.Stat(wantCache); statErr != nil || !info.IsDir() { t.Fatalf("UV cache directory is not usable: info=%v error=%v", info, statErr) } + wantGoPath := filepath.Join(root, ".coding-ethos", "cache", "go-path") + wantGoModCache := filepath.Join(wantGoPath, "pkg", "mod") + for name, want := range map[string]string{ + "GOPATH": wantGoPath, + "GOMODCACHE": wantGoModCache, + } { + if got := os.Getenv(name); got != want { + t.Fatalf("%s = %q, want %q", name, got, want) + } + if info, statErr := os.Stat(want); statErr != nil || !info.IsDir() { + t.Fatalf("%s directory is not usable: info=%v error=%v", name, info, statErr) + } + } - wantProjectEnv := filepath.Join(root, ".coding-ethos", "cache", "uv-project-env") - if got := os.Getenv("UV_PROJECT_ENVIRONMENT"); got != wantProjectEnv { - t.Fatalf("UV_PROJECT_ENVIRONMENT = %q, want %q", got, wantProjectEnv) + if got := os.Getenv( + "UV_PROJECT_ENVIRONMENT", + ); got != "/previous/uv-project-environment" { + t.Fatalf("UV_PROJECT_ENVIRONMENT changed process-wide: %q", got) } - if info, statErr := os.Stat(wantProjectEnv); statErr != nil || !info.IsDir() { - t.Fatalf("UV project environment is not usable: info=%v error=%v", info, statErr) + if got := os.Getenv("UV_FROZEN"); got != "0" { + t.Fatalf("UV_FROZEN changed process-wide: %q", got) } - if got := os.Getenv("UV_FROZEN"); got != "1" { - t.Fatalf("UV_FROZEN = %q, want %q", got, "1") + projectEnvRoot := filepath.Join(root, ".coding-ethos", "cache", "uv-project-env") + if _, statErr := os.Stat(projectEnvRoot); !os.IsNotExist(statErr) { + t.Fatalf("process preparation created uv project environment: %v", statErr) } restore() + if got := os.Getenv("GOPATH"); got != "/previous/go-path" { + t.Fatalf("restored GOPATH = %q", got) + } + if got := os.Getenv("GOMODCACHE"); got != "/previous/go-mod-cache" { + t.Fatalf("restored GOMODCACHE = %q", got) + } if got := os.Getenv("UV_CACHE_DIR"); got != "/previous/uv-cache" { t.Fatalf("restored UV_CACHE_DIR = %q", got) } @@ -81,6 +104,9 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Setenv("GIT_CONFIG_VALUE_0", "test@example.com") t.Setenv("CODING_ETHOS_REAL_GIT", "/tmp/hook-real-git") t.Setenv("GOCACHE", "/tmp/host-go-cache") + t.Setenv("GOPATH", "/tmp/host-go-path") + t.Setenv("GOMODCACHE", "/tmp/host-go-mod-cache") + t.Setenv("UV_PROJECT_ENVIRONMENT", "/tmp/host-uv-project") t.Setenv("UV_FROZEN", "0") t.Setenv("PATH", shimDir+string(os.PathListSeparator)+"/usr/bin") t.Setenv(consumerRootEnv, repo) @@ -90,7 +116,11 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Setenv("CODING_ETHOS_SANDBOX_ACTIVE", "1") t.Setenv("CODING_ETHOS_SANDBOX_ROOT", repo) - env, err := externalToolEnv([]string{"KEEP_EXTRA=1"}) + env, err := externalToolEnv(externalToolRequest{ + Dir: repo, + Command: []string{"go", "test", "./..."}, + Env: []string{"KEEP_EXTRA=1"}, + }) if err != nil { t.Fatalf("externalToolEnv() returned error: %v", err) } @@ -132,6 +162,24 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Fatalf("externalToolEnv did not replace host Go cache: %#v", env) } + wantGoPath := filepath.Join(repo, ".coding-ethos", "cache", "go-path") + for name, want := range map[string]string{ + "GOPATH": wantGoPath, + "GOMODCACHE": filepath.Join(wantGoPath, "pkg", "mod"), + } { + if got := externalToolTestRequiredEnvValue(t, env, name); got != want { + t.Fatalf("externalToolEnv %s = %q, want %q", name, got, want) + } + } + for _, unwanted := range []string{ + "GOPATH=/tmp/host-go-path", + "GOMODCACHE=/tmp/host-go-mod-cache", + } { + if slices.Contains(env, unwanted) { + t.Fatalf("externalToolEnv kept host Go path %q: %#v", unwanted, env) + } + } + if !slices.Contains( env, "GOTMPDIR="+filepath.Join(repo, ".coding-ethos/cache/go-tmp"), @@ -153,15 +201,11 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Fatalf("externalToolEnv did not set uv cache dir: %#v", env) } - if !slices.Contains( - env, - "UV_PROJECT_ENVIRONMENT="+filepath.Join(repo, ".coding-ethos/cache/uv-project-env"), - ) { - t.Fatalf("externalToolEnv did not set uv project environment: %#v", env) + if _, found := externalToolTestEnvValue(env, "UV_PROJECT_ENVIRONMENT"); found { + t.Fatalf("externalToolEnv leaked uv project scope into non-uv tool: %#v", env) } - - if !slices.Contains(env, "UV_FROZEN=1") || slices.Contains(env, "UV_FROZEN=0") { - t.Fatalf("externalToolEnv did not freeze the sealed uv project: %#v", env) + if _, found := externalToolTestEnvValue(env, "UV_FROZEN"); found { + t.Fatalf("externalToolEnv leaked frozen mode into non-uv tool: %#v", env) } for _, item := range env { @@ -183,6 +227,146 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Fatalf("externalToolEnv omitted PATH: %#v", env) } +func TestExternalToolEnvIsolatesActiveUVProjects(t *testing.T) { + repo := t.TempDir() + firstProject := filepath.Join(repo, "first") + secondProject := filepath.Join(repo, "second") + for _, project := range []string{firstProject, secondProject} { + mustWriteTestFile( + t, + filepath.Join(project, "pyproject.toml"), + "[project]\nname = 'fixture'\n", + ) + } + + t.Chdir(repo) + t.Setenv(consumerRootEnv, repo) + + firstEnv, err := externalToolEnv(externalToolRequest{ + Dir: firstProject, + Command: []string{"uv", "run", "python", "-V"}, + }) + if err != nil { + t.Fatalf("first externalToolEnv: %v", err) + } + secondEnv, err := externalToolEnv(externalToolRequest{ + Dir: repo, + Command: []string{ + "uv", "run", "--project", "second", "python", "-V", + }, + }) + if err != nil { + t.Fatalf("second externalToolEnv: %v", err) + } + + firstPath := externalToolTestRequiredEnvValue( + t, + firstEnv, + "UV_PROJECT_ENVIRONMENT", + ) + secondPath := externalToolTestRequiredEnvValue( + t, + secondEnv, + "UV_PROJECT_ENVIRONMENT", + ) + if firstPath == secondPath { + t.Fatalf("distinct uv projects shared environment %q", firstPath) + } + wantRoot := filepath.Join(repo, ".coding-ethos", "cache", "uv-project-env") + for _, path := range []string{firstPath, secondPath} { + if !strings.HasPrefix(path, wantRoot+string(os.PathSeparator)) { + t.Fatalf("uv project environment %q is outside %q", path, wantRoot) + } + if info, statErr := os.Stat(path); statErr != nil || !info.IsDir() { + t.Fatalf("uv project environment is not usable: info=%v error=%v", info, statErr) + } + } + + firstCache := externalToolTestRequiredEnvValue(t, firstEnv, "UV_CACHE_DIR") + secondCache := externalToolTestRequiredEnvValue(t, secondEnv, "UV_CACHE_DIR") + if firstCache != secondCache { + t.Fatalf("uv projects did not share cache: %q != %q", firstCache, secondCache) + } +} + +func TestExternalToolEnvFreezesOnlySealedOrExplicitUVProjects(t *testing.T) { + repo := t.TempDir() + sealedProject := filepath.Join(repo, "sealed") + unsealedProject := filepath.Join(repo, "unsealed") + for _, project := range []string{sealedProject, unsealedProject} { + mustWriteTestFile( + t, + filepath.Join(project, "pyproject.toml"), + "[project]\nname = 'fixture'\n", + ) + } + mustWriteTestFile(t, filepath.Join(sealedProject, "uv.lock"), "version = 1\n") + + runGitTestCommandInDir(t, repo, "init", "--quiet") + runGitTestCommandInDir( + t, + repo, + "add", + "sealed/pyproject.toml", + "sealed/uv.lock", + "unsealed/pyproject.toml", + ) + runGitTestCommandInDir( + t, + repo, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgSign=false", + "commit", + "--quiet", + "--no-gpg-sign", + "-m", + "fixture", + ) + mustWriteTestFile(t, filepath.Join(unsealedProject, "uv.lock"), "version = 1\n") + + t.Chdir(repo) + t.Setenv(consumerRootEnv, repo) + + sealedEnv, err := externalToolEnv(externalToolRequest{ + Dir: sealedProject, + Command: []string{"uv", "run", "python", "-V"}, + }) + if err != nil { + t.Fatalf("sealed externalToolEnv: %v", err) + } + if got := externalToolTestRequiredEnvValue(t, sealedEnv, "UV_FROZEN"); got != "1" { + t.Fatalf("sealed UV_FROZEN = %q, want 1", got) + } + + unsealedEnv, err := externalToolEnv(externalToolRequest{ + Dir: unsealedProject, + Command: []string{"uv", "run", "python", "-V"}, + }) + if err != nil { + t.Fatalf("unsealed externalToolEnv: %v", err) + } + if value, found := externalToolTestEnvValue(unsealedEnv, "UV_FROZEN"); found { + t.Fatalf("unsealed UV_FROZEN = %q, want absent", value) + } + + explicitEnv, err := externalToolEnv(externalToolRequest{ + Dir: unsealedProject, + Command: []string{ + "uv", "run", "--frozen", "python", "-V", + }, + }) + if err != nil { + t.Fatalf("explicit frozen externalToolEnv: %v", err) + } + if got := externalToolTestRequiredEnvValue(t, explicitEnv, "UV_FROZEN"); got != "1" { + t.Fatalf("explicit UV_FROZEN = %q, want 1", got) + } +} + func TestExternalToolEnvAddsUsablePathWhenInheritedPathMissing(t *testing.T) { original, hadOriginal := os.LookupEnv("PATH") if err := os.Unsetenv("PATH"); err != nil { @@ -196,7 +380,9 @@ func TestExternalToolEnvAddsUsablePathWhenInheritedPathMissing(t *testing.T) { } }) - env, err := externalToolEnv(nil) + env, err := externalToolEnv(externalToolRequest{ + Command: []string{"go", "version"}, + }) if err != nil { t.Fatalf("externalToolEnv() returned error: %v", err) } @@ -219,6 +405,28 @@ func TestExternalToolEnvAddsUsablePathWhenInheritedPathMissing(t *testing.T) { t.Fatalf("externalToolEnv omitted PATH: %#v", env) } +func externalToolTestRequiredEnvValue(t *testing.T, env []string, name string) string { + t.Helper() + + value, found := externalToolTestEnvValue(env, name) + if !found { + t.Fatalf("external tool environment omitted %s: %#v", name, env) + } + + return value +} + +func externalToolTestEnvValue(env []string, name string) (string, bool) { + for _, item := range env { + itemName, value, found := strings.Cut(item, "=") + if found && itemName == name { + return value, true + } + } + + return "", false +} + func TestExternalToolCacheEnvFailsWhenCacheDirsCannotBeCreated(t *testing.T) { root := t.TempDir() if err := os.WriteFile( diff --git a/go/internal/hookrunnercli/python_policies.go b/go/internal/hookrunnercli/python_policies.go index 7cc62fac..2345f265 100644 --- a/go/internal/hookrunnercli/python_policies.go +++ b/go/internal/hookrunnercli/python_policies.go @@ -913,7 +913,10 @@ func runPytestCommand(settings pytestGateSettings) (pytestRunResult, error) { ) cmd.Dir = settings.ConsumerRoot - env, envErr := externalToolEnv(nil) + env, envErr := externalToolEnv(externalToolRequest{ + Dir: settings.ConsumerRoot, + Command: settings.TestCommand, + }) if envErr != nil { return result, envErr } diff --git a/go/internal/hooks/gate_exit_status.go b/go/internal/hooks/gate_exit_status.go index eaf9fc4e..b2f1aa89 100644 --- a/go/internal/hooks/gate_exit_status.go +++ b/go/internal/hooks/gate_exit_status.go @@ -24,6 +24,8 @@ const ( "by a shell fallback." gatePipelineReason = "Required repository gate pipelines must enable " + "pipefail so the gate status remains authoritative." + gateNestingReason = "Required repository gate inspection exceeded the " + + "supported shell nesting depth." gateSequenceReason = "Commands after a required repository gate must " + "capture and return the gate's exact exit status." ) @@ -55,7 +57,11 @@ func maskedRequiredGateStatus( inheritedPipefail bool, depth int, ) (string, bool) { - if depth > maxGateShellDepth || strings.TrimSpace(command) == "" { + if depth > maxGateShellDepth { + return gateNestingReason, true + } + + if strings.TrimSpace(command) == "" { return "", false } @@ -64,13 +70,17 @@ func maskedRequiredGateStatus( return "", false } - pipefail := inheritedPipefail || parsed.enablesPipefail() + pipefail := inheritedPipefail for index := range parsed.segments { reason, masked := parsed.maskedSegmentStatus(index, pipefail, depth) if masked { return reason, true } + + if isPipefailCommand(gateExecutableArgv(parsed.segments[index])) { + pipefail = true + } } return "", false @@ -170,16 +180,6 @@ func parseGateShell(command string) (gateShell, error) { return parsed, nil } -func (parsed gateShell) enablesPipefail() bool { - for _, segment := range parsed.segments { - if isPipefailCommand(gateExecutableArgv(segment)) { - return true - } - } - - return false -} - func isPipefailCommand(argv []string) bool { return len(argv) >= 3 && argv[0] == "set" && argv[1] == "-o" && argv[2] == "pipefail" @@ -441,8 +441,7 @@ func nestedShellScript(argv []string) (string, bool, bool) { continue } - if strings.HasPrefix(argv[index], "-") && - strings.Contains(argv[index], "c") && index+1 < len(argv) { + if shellCommandStringOption(argv[index]) && index+1 < len(argv) { return argv[index+1], pipefail, true } } @@ -450,6 +449,12 @@ func nestedShellScript(argv []string) (string, bool, bool) { return "", pipefail, false } +func shellCommandStringOption(argument string) bool { + return strings.HasPrefix(argument, "-") && + !strings.HasPrefix(argument, "--") && + strings.Contains(argument[1:], "c") +} + func shellExecutable(argument string) bool { return slices.Contains( []string{"bash", "dash", "sh"}, @@ -473,14 +478,16 @@ func requiredGateArgv(argv []string) bool { } func requiredExecutableGate(name string, arguments []string) bool { + if isPythonCommand(name) { + return len(arguments) > 1 && arguments[0] == "-m" && + arguments[1] == pytestExecutable + } + switch name { case "cargo": return requiredCargoGate(arguments) case "go": return requiredGoGate(arguments) - case pythonExecutable, "python3": - return len(arguments) > 1 && arguments[0] == "-m" && - arguments[1] == pytestExecutable case pytestExecutable, "ghprsq": return true case preCommitExecutable: diff --git a/go/internal/hooks/gate_exit_status_test.go b/go/internal/hooks/gate_exit_status_test.go index d96e440b..0acd9053 100644 --- a/go/internal/hooks/gate_exit_status_test.go +++ b/go/internal/hooks/gate_exit_status_test.go @@ -13,10 +13,12 @@ func TestRequiredGateExitStatusBlocksMaskedFailures(t *testing.T) { "make check | tail -100", "make check || true", "bash -c 'make check; echo done'", + "bash --norc -c 'make check || true'", "git -c core.useBuiltinFSMonitor=false commit -m verified | tee commit.log", "cargo --locked test | tee cargo.log", "go -C ./go test ./... | tee go.log", "python3 -m pytest | tee pytest.log", + "python3.13 -m pytest | tee pytest.log", "env -u CI make check | tee make.log", "nice make check | tee make.log", "timeout 30s make check | tee make.log", @@ -25,6 +27,7 @@ func TestRequiredGateExitStatusBlocksMaskedFailures(t *testing.T) { "make go-e2e-test | tee go-e2e.log", "make lint | tee lint.log", "make purrdf-extractor-check | tee purrdf.log", + "make check | tail -100 && set -o pipefail", } { route := requiredGateExitStatusRouteFor(Event{ HookEventName: eventPreToolUse, @@ -39,6 +42,23 @@ func TestRequiredGateExitStatusBlocksMaskedFailures(t *testing.T) { } } +func TestRequiredGateExitStatusFailsClosedBeyondShellDepth(t *testing.T) { + t.Parallel() + + reason, masked := maskedRequiredGateStatus( + "make check", + false, + maxGateShellDepth+1, + ) + if !masked || reason != gateNestingReason { + t.Fatalf( + "over-nested inspection = (%q, %t), want fail-closed nesting block", + reason, + masked, + ) + } +} + func TestRequiredGateExitStatusAllowsAuthoritativeStatus(t *testing.T) { t.Parallel() diff --git a/go/internal/lint/runner_test.go b/go/internal/lint/runner_test.go index a37f431d..cae6fd7c 100644 --- a/go/internal/lint/runner_test.go +++ b/go/internal/lint/runner_test.go @@ -310,6 +310,32 @@ func TestRunAllowsExactStagedGeneratedToolConfig(t *testing.T) { } } +func TestExactStagedGeneratedToolConfigRejectsUnchangedIndexEntry(t *testing.T) { + t.Parallel() + + ethosRoot := repoRootForLintTest(t) + repo := initializedLintGitRepo(t) + + _, err := toolconfigs.Sync(ethosRoot, repo, "") + if err != nil { + t.Fatalf("sync generated tool configs: %v", err) + } + runLintGit(t, repo, "add", ".bandit.yml") + runLintGit(t, repo, "commit", "-m", "test: establish generated baseline") + + otherPath := filepath.Join(repo, "other.txt") + if err = os.WriteFile(otherPath, []byte("staged change\n"), 0o600); err != nil { + t.Fatalf("write staged change: %v", err) + } + runLintGit(t, repo, "add", "other.txt") + + bundle := compiledRepoLintBundle(t) + trusted := generatedtrust.ExactStagedFiles(bundle, repo, []string{".bandit.yml"}) + if len(trusted) != 0 { + t.Fatalf("unchanged generated index entry was trusted: %#v", trusted) + } +} + func TestRunBlocksDivergentStagedGeneratedToolConfig(t *testing.T) { t.Parallel() @@ -385,6 +411,7 @@ func initializedLintGitRepo(t *testing.T) string { runLintGit(t, repo, "init") runLintGit(t, repo, "config", "user.email", "test@example.com") runLintGit(t, repo, "config", "user.name", "Test") + runLintGit(t, repo, "config", "commit.gpgsign", "false") return repo } diff --git a/go/internal/managedcapture/capture.go b/go/internal/managedcapture/capture.go index 5db596f6..2144633c 100644 --- a/go/internal/managedcapture/capture.go +++ b/go/internal/managedcapture/capture.go @@ -31,6 +31,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/policy" "blackcat.ca/coding-ethos/go/internal/processstatus" "blackcat.ca/coding-ethos/go/internal/sandbox" + "blackcat.ca/coding-ethos/go/internal/toolprotocol" "blackcat.ca/coding-ethos/go/toolcatalog" ) @@ -508,7 +509,11 @@ func capturedProcessArgv(plan sandbox.Plan) []string { return append([]string{plan.Executable}, plan.Args...) } -func capturedProcessEnv(environ []string, cacheEnv sandboxCacheEnvironment) []string { +func capturedProcessEnv( + environ []string, + cacheEnv sandboxCacheEnvironment, + tool string, +) []string { out := make([]string, 0, len(environ)) hasPath := false @@ -556,6 +561,9 @@ func capturedProcessEnv(environ []string, cacheEnv sandboxCacheEnvironment) []st } out = append(out, cacheEnv.items()...) + if tool == toolprotocol.ActionlintTool { + out = append(out, toolprotocol.ActionlintShellcheckEnvironment()) + } return out } diff --git a/go/internal/managedcapture/capture_process.go b/go/internal/managedcapture/capture_process.go index 0487c718..7d87d60b 100644 --- a/go/internal/managedcapture/capture_process.go +++ b/go/internal/managedcapture/capture_process.go @@ -169,7 +169,7 @@ func startCapturedOSProcess( argv, &os.ProcAttr{ Dir: request.Cwd, - Env: capturedProcessEnv(os.Environ(), cacheEnv), + Env: capturedProcessEnv(os.Environ(), cacheEnv, request.Tool), Files: files, Sys: capturedProcessSysProcAttr(cgroup, evidence), }, diff --git a/go/internal/managedcapture/capture_test.go b/go/internal/managedcapture/capture_test.go index a4002d14..50efecd8 100644 --- a/go/internal/managedcapture/capture_test.go +++ b/go/internal/managedcapture/capture_test.go @@ -24,6 +24,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/hookoutput" "blackcat.ca/coding-ethos/go/internal/policy" "blackcat.ca/coding-ethos/go/internal/sandbox" + "blackcat.ca/coding-ethos/go/internal/toolprotocol" "blackcat.ca/coding-ethos/go/toolcatalog" ) @@ -2060,11 +2061,11 @@ func TestRunCapturedToolLogsForcedStructuredFormats(t *testing.T) { "", io.Discard, ) + content := singleTraceContent(t, repo) if exitCode != 1 { - t.Fatalf("exit code = %d, want 1", exitCode) + t.Fatalf("exit code = %d, want 1; trace:\n%s", exitCode, content) } - content := singleTraceContent(t, repo) for _, want := range []string{ `"source_tool": "` + test.wantTool + `"`, `"file": "` + test.wantFile + `"`, @@ -2447,6 +2448,8 @@ func TestCapturedProcessEnvRemovesCodingEthosGitShimPath(t *testing.T) { "OTHER=value", "TMPDIR=/tmp/host", "GOCACHE=/tmp/go-cache", + "GOPATH=/tmp/go-path", + "GOMODCACHE=/tmp/go-mod-cache", "GOLANGCI_LINT_CACHE=/tmp/golangci-cache", "GOROOT=/tmp/go-root", "CGO_ENABLED=0", @@ -2456,13 +2459,15 @@ func TestCapturedProcessEnvRemovesCodingEthosGitShimPath(t *testing.T) { }, sandboxCacheEnvironment{ TempDir: "/repo/.coding-ethos/cache/sandbox-tmp", GoCache: "/repo/.coding-ethos/cache/go-build", + GoPath: "/repo/.coding-ethos/cache/go-path", + GoModCache: "/repo/.coding-ethos/cache/go-path/pkg/mod", GolangCILintDir: "/repo/.coding-ethos/cache/golangci-lint", GoRoot: "/repo/go-root", CGOEnabled: "1", CC: "/usr/bin/gcc", CompilerPath: "/usr/bin", Assembler: "/usr/bin/as", - }) + }, "ruff") if !capturedEnvPathContains(env, realDir) { t.Fatalf("captured env PATH = %#v, want entry %q", env, realDir) @@ -2479,6 +2484,8 @@ func TestCapturedProcessEnvRemovesCodingEthosGitShimPath(t *testing.T) { for _, want := range []string{ "GOCACHE=/repo/.coding-ethos/cache/go-build", + "GOPATH=/repo/.coding-ethos/cache/go-path", + "GOMODCACHE=/repo/.coding-ethos/cache/go-path/pkg/mod", "GOLANGCI_LINT_CACHE=/repo/.coding-ethos/cache/golangci-lint", "GOROOT=/repo/go-root", "CGO_ENABLED=1", @@ -2492,6 +2499,15 @@ func TestCapturedProcessEnvRemovesCodingEthosGitShimPath(t *testing.T) { } } + for _, inherited := range []string{ + "GOPATH=/tmp/go-path", + "GOMODCACHE=/tmp/go-mod-cache", + } { + if slices.Contains(env, inherited) { + t.Fatalf("captured env kept host Go path %q: %#v", inherited, env) + } + } + for _, blocked := range []string{ "CODE_ETHOS_CONSUMER_ROOT=/repo", "CODING_ETHOS_EXEC_STACK=coding-ethos-run", @@ -2520,10 +2536,58 @@ func capturedEnvPathContains(env []string, entry string) bool { return false } +func TestCapturedProcessEnvMarksOnlyManagedActionlint(t *testing.T) { + t.Parallel() + + inherited := []string{ + toolprotocol.ActionlintShellcheckEnv + "=untrusted", + "OTHER=value", + } + want := toolprotocol.ActionlintShellcheckEnvironment() + + actionlintEnv := capturedProcessEnv( + inherited, + sandboxCacheEnvironment{}, + toolprotocol.ActionlintTool, + ) + if count := countEnvironmentEntry(actionlintEnv, want); count != 1 { + t.Fatalf( + "managed actionlint protocol marker count = %d, want 1: %#v", + count, + actionlintEnv, + ) + } + + shellcheckEnv := capturedProcessEnv( + inherited, + sandboxCacheEnvironment{}, + toolprotocol.ShellcheckTool, + ) + for _, item := range shellcheckEnv { + if strings.HasPrefix(item, toolprotocol.ActionlintShellcheckEnv+"=") { + t.Fatalf( + "ordinary ShellCheck inherited actionlint protocol marker: %#v", + shellcheckEnv, + ) + } + } +} + +func countEnvironmentEntry(env []string, want string) int { + count := 0 + for _, item := range env { + if item == want { + count++ + } + } + + return count +} + func TestCapturedProcessEnvAddsUsablePathWhenInheritedPathMissing(t *testing.T) { t.Parallel() - env := capturedProcessEnv([]string{"OTHER=value"}, sandboxCacheEnvironment{}) + env := capturedProcessEnv([]string{"OTHER=value"}, sandboxCacheEnvironment{}, "ruff") for _, item := range env { name, value, ok := strings.Cut(item, "=") diff --git a/go/internal/managedcapture/sandbox_cache_env.go b/go/internal/managedcapture/sandbox_cache_env.go index 4c761ccc..1acef6c7 100644 --- a/go/internal/managedcapture/sandbox_cache_env.go +++ b/go/internal/managedcapture/sandbox_cache_env.go @@ -19,6 +19,8 @@ type sandboxCacheEnvironment struct { TempDir string RuntimeDir string GoCache string + GoPath string + GoModCache string GolangCILintDir string GoRoot string CGOEnabled string @@ -103,6 +105,8 @@ func (environment sandboxCacheEnvironment) value(name string) string { "TMPDIR": environment.TempDir, "XDG_RUNTIME_DIR": environment.RuntimeDir, "GOCACHE": environment.GoCache, + "GOPATH": environment.GoPath, + "GOMODCACHE": environment.GoModCache, "GOLANGCI_LINT_CACHE": environment.GolangCILintDir, "GOROOT": environment.GoRoot, "CGO_ENABLED": environment.CGOEnabled, @@ -120,6 +124,8 @@ func (environment sandboxCacheEnvironment) names() []string { "TMPDIR", "XDG_RUNTIME_DIR", "GOCACHE", + "GOPATH", + "GOMODCACHE", "GOLANGCI_LINT_CACHE", "GOROOT", "CGO_ENABLED", @@ -184,6 +190,19 @@ func sandboxCacheEnv( return sandboxCacheEnvironment{}, err } + goPath := filepath.Join(root, sandbox.SandboxGoPath) + goModCache := filepath.Join(root, sandbox.SandboxGoModCachePath) + + err = makeSandboxDir(goPath, "Go path") + if err != nil { + return sandboxCacheEnvironment{}, err + } + + err = makeSandboxDir(goModCache, "Go module cache") + if err != nil { + return sandboxCacheEnvironment{}, err + } + golangCILintDir := filepath.Join(root, sandbox.SandboxGolangCIPath) err = makeSandboxDir(golangCILintDir, "golangci-lint cache") @@ -201,6 +220,8 @@ func sandboxCacheEnv( RuntimeDir: runtimeDir, CleanupTemp: cleanupTemp, GoCache: goCache, + GoPath: goPath, + GoModCache: goModCache, GolangCILintDir: golangCILintDir, GoRoot: managedGoRoot(ctx), CGOEnabled: "1", diff --git a/go/internal/managedcapture/sandbox_cache_env_test.go b/go/internal/managedcapture/sandbox_cache_env_test.go new file mode 100644 index 00000000..5704315d --- /dev/null +++ b/go/internal/managedcapture/sandbox_cache_env_test.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package managedcapture + +import ( + "context" + "os" + "path/filepath" + "testing" + + "blackcat.ca/coding-ethos/go/internal/sandbox" +) + +func TestSandboxCacheEnvCreatesConsumerGoPath(t *testing.T) { + root := t.TempDir() + + environment, err := sandboxCacheEnv(context.Background(), captureRequest{ + Cwd: root, + TraceRoot: root, + Tool: "ruff", + }) + if err != nil { + t.Fatalf("sandboxCacheEnv: %v", err) + } + + wantGoPath := filepath.Join(root, sandbox.SandboxGoPath) + wantGoModCache := filepath.Join(root, sandbox.SandboxGoModCachePath) + if environment.GoPath != wantGoPath { + t.Fatalf("GOPATH = %q, want %q", environment.GoPath, wantGoPath) + } + if environment.GoModCache != wantGoModCache { + t.Fatalf("GOMODCACHE = %q, want %q", environment.GoModCache, wantGoModCache) + } + + for name, path := range map[string]string{ + "GOPATH": environment.GoPath, + "GOMODCACHE": environment.GoModCache, + } { + info, statErr := os.Stat(path) + if statErr != nil || !info.IsDir() { + t.Fatalf("%s directory is not usable: info=%v error=%v", name, info, statErr) + } + } +} diff --git a/go/internal/policygitcli/main.go b/go/internal/policygitcli/main.go index 77af9fae..c04e10ad 100644 --- a/go/internal/policygitcli/main.go +++ b/go/internal/policygitcli/main.go @@ -249,7 +249,7 @@ func gitGlobalOptionStartsArgv(argument string) bool { "--bare", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--html-path", "--man-path", "--info-path", "--config-env", "--literal-pathspecs", "--glob-pathspecs", "--noglob-pathspecs", "--icase-pathspecs", - "--version", "--help": + "-v", "--version", "-h", "--help": return true default: return false diff --git a/go/internal/policygitcli/main_internal_test.go b/go/internal/policygitcli/main_internal_test.go index 6187d5bf..794cb6e4 100644 --- a/go/internal/policygitcli/main_internal_test.go +++ b/go/internal/policygitcli/main_internal_test.go @@ -91,6 +91,16 @@ func TestParsePolicyGitArgsPreservesGitGlobalOptions(t *testing.T) { } } +func TestGitGlobalOptionStartsArgvRecognizesShortMetaOptions(t *testing.T) { + t.Parallel() + + for _, option := range []string{"-h", "-v"} { + if !gitGlobalOptionStartsArgv(option) { + t.Fatalf("gitGlobalOptionStartsArgv(%q) = false, want true", option) + } + } +} + func TestParsePolicyGitArgsHonorsExplicitBoundary(t *testing.T) { t.Parallel() diff --git a/go/internal/sandbox/sandbox.go b/go/internal/sandbox/sandbox.go index b6ef5c9d..afa68fb4 100644 --- a/go/internal/sandbox/sandbox.go +++ b/go/internal/sandbox/sandbox.go @@ -25,16 +25,18 @@ const ( BackendNative = "native" - cgroupLineParts = 3 - toolFallbackName = "tool" - nativeSandboxBinary = "coding-ethos-sandbox" - nativeProbeTimeout = 10 - nativeProbeDirMode = 0o700 - nativeProbeFileMode = 0o700 - nativeProbeWriteMode = 0o600 - SandboxTempWritePath = ".coding-ethos/cache/sandbox-tmp" - SandboxGoCachePath = ".coding-ethos/cache/go-build" - SandboxGolangCIPath = ".coding-ethos/cache/golangci-lint" + cgroupLineParts = 3 + toolFallbackName = "tool" + nativeSandboxBinary = "coding-ethos-sandbox" + nativeProbeTimeout = 10 + nativeProbeDirMode = 0o700 + nativeProbeFileMode = 0o700 + nativeProbeWriteMode = 0o600 + SandboxTempWritePath = ".coding-ethos/cache/sandbox-tmp" + SandboxGoCachePath = ".coding-ethos/cache/go-build" + SandboxGoPath = ".coding-ethos/cache/go-path" + SandboxGoModCachePath = SandboxGoPath + "/pkg/mod" + SandboxGolangCIPath = ".coding-ethos/cache/golangci-lint" ) var ( @@ -673,6 +675,7 @@ func (request Request) evidence() Evidence { ) writePaths = append(writePaths, SandboxTempWritePath) writePaths = append(writePaths, SandboxGoCachePath) + writePaths = append(writePaths, SandboxGoPath) writePaths = append(writePaths, SandboxGolangCIPath) writePaths = append(writePaths, nativeSystemWritePaths()...) readPaths := append([]string(nil), request.Capabilities.ReadPaths...) diff --git a/go/internal/toolprotocol/actionlint_shellcheck.go b/go/internal/toolprotocol/actionlint_shellcheck.go new file mode 100644 index 00000000..08434745 --- /dev/null +++ b/go/internal/toolprotocol/actionlint_shellcheck.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +// Package toolprotocol defines explicit contracts between managed tools. +package toolprotocol + +const ( + // ActionlintTool identifies the managed actionlint process. + ActionlintTool = "actionlint" + // ShellcheckTool identifies actionlint's ShellCheck dependency. + ShellcheckTool = "shellcheck" + + // ActionlintShellcheckEnv marks ShellCheck requests made by managed actionlint. + ActionlintShellcheckEnv = "CODE_ETHOS_ACTIONLINT_SHELLCHECK_PROTOCOL" + // ActionlintShellcheckJSONStdinV1 identifies the raw JSON-on-stdin protocol. + ActionlintShellcheckJSONStdinV1 = "json-stdin-v1" +) + +// ActionlintShellcheckEnvironment returns the managed actionlint marker entry. +func ActionlintShellcheckEnvironment() string { + return ActionlintShellcheckEnv + "=" + ActionlintShellcheckJSONStdinV1 +} + +// IsActionlintShellcheckJSONStdin reports whether a request matches the raw protocol. +func IsActionlintShellcheckJSONStdin(marker, tool string, args []string) bool { + if marker != ActionlintShellcheckJSONStdinV1 || + tool != ShellcheckTool || + len(args) == 0 || + args[len(args)-1] != "-" { + return false + } + + for index, arg := range args { + if (arg == "-f" || arg == "--format") && + index+1 < len(args) && + args[index+1] == "json" { + return true + } + + if arg == "-f=json" || arg == "--format=json" { + return true + } + } + + return false +} diff --git a/go/internal/toolprotocol/actionlint_shellcheck_test.go b/go/internal/toolprotocol/actionlint_shellcheck_test.go new file mode 100644 index 00000000..9fd7f46e --- /dev/null +++ b/go/internal/toolprotocol/actionlint_shellcheck_test.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package toolprotocol + +import "testing" + +func TestIsActionlintShellcheckJSONStdin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + marker string + tool string + args []string + want bool + }{ + { + name: "marked actionlint json stdin", + marker: ActionlintShellcheckJSONStdinV1, + tool: ShellcheckTool, + args: []string{"--norc", "-f", "json", "-x", "--shell", "bash", "-"}, + want: true, + }, + { + name: "marked long json format", + marker: ActionlintShellcheckJSONStdinV1, + tool: ShellcheckTool, + args: []string{"--format=json", "-"}, + want: true, + }, + { + name: "marker absent", + tool: ShellcheckTool, + args: []string{"-f", "json", "-"}, + }, + { + name: "unknown marker", + marker: "json-stdin-v2", + tool: ShellcheckTool, + args: []string{"-f", "json", "-"}, + }, + { + name: "wrong tool", + marker: ActionlintShellcheckJSONStdinV1, + tool: ActionlintTool, + args: []string{"-f", "json", "-"}, + }, + { + name: "not stdin", + marker: ActionlintShellcheckJSONStdinV1, + tool: ShellcheckTool, + args: []string{"-f", "json", "script.sh"}, + }, + { + name: "not json", + marker: ActionlintShellcheckJSONStdinV1, + tool: ShellcheckTool, + args: []string{"-f", "gcc", "-"}, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := IsActionlintShellcheckJSONStdin(test.marker, test.tool, test.args) + if got != test.want { + t.Fatalf("IsActionlintShellcheckJSONStdin() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/pre-commit/hooks/pyproject.toml b/pre-commit/hooks/pyproject.toml index 459cb3a2..e2f9e9a0 100644 --- a/pre-commit/hooks/pyproject.toml +++ b/pre-commit/hooks/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "mypy>=1.19.1", "mypy-extensions>=1.1.0", "pandas-stubs>=3.0.0.260204", - "pip>=26.1", + "pip>=26.2", "pydantic>=2.0.0", "pylint>=4.0.4", "pyright>=1.1.408", @@ -54,7 +54,7 @@ packages = ["coding_ethos_hooks"] [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { pip = "2026-04-26T21:00:06Z", sqlfluff = "2026-05-15T00:00:00Z" } +exclude-newer-package = { pip = "2026-07-29T21:57:56Z", sqlfluff = "2026-05-15T00:00:00Z" } [tool.pyright] pythonVersion = "3.13" diff --git a/pre-commit/hooks/uv.lock b/pre-commit/hooks/uv.lock index a75e95d2..36c6d8a8 100644 --- a/pre-commit/hooks/uv.lock +++ b/pre-commit/hooks/uv.lock @@ -11,7 +11,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -pip = "2026-04-26T21:00:06Z" +pip = "2026-07-29T21:57:56Z" sqlfluff = "2026-05-15T00:00:00Z" [[package]] @@ -257,7 +257,7 @@ requires-dist = [ { name = "mypy", specifier = ">=1.19.1" }, { name = "mypy-extensions", specifier = ">=1.1.0" }, { name = "pandas-stubs", specifier = ">=3.0.0.260204" }, - { name = "pip", specifier = ">=26.1" }, + { name = "pip", specifier = ">=26.2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pylint", specifier = ">=4.0.4" }, { name = "pyright", specifier = ">=1.1.408" }, @@ -705,11 +705,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/96/e6f8e9d9d7b9cc4457092712a7e919c3186aa2c2fa9ffed2c5d29cc947e8/pip-26.2.tar.gz", hash = "sha256:2d8542afcc84cdd8e846c2b36b2861fad1da376dd98f8e7113e9108a3c331690", size = 1848845, upload-time = "2026-07-29T21:57:56.407Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" }, + { url = "https://files.pythonhosted.org/packages/62/36/a3aed958d60531cb442b7ab4596cda7b3621cfb916f8ae1d6769795c7dc1/pip-26.2-py3-none-any.whl", hash = "sha256:931c303696af6fa3417112103b1cad26890e5a07eccb5b99783700e33f2b8aad", size = 1816475, upload-time = "2026-07-29T21:57:54.763Z" }, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 8db240b8..dfa8aea8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ packages = ["coding_ethos"] [tool.uv] exclude-newer = "7 days" -exclude-newer-package = { pip = "2026-05-31T17:33:57Z", sqlfluff = "2026-05-15T00:00:00Z" } +exclude-newer-package = { pip = "2026-07-29T21:57:56Z", sqlfluff = "2026-05-15T00:00:00Z" } managed = true [tool.uv.workspace] diff --git a/uv.lock b/uv.lock index a52bd661..8ddd9f5b 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -pip = "2026-05-31T17:33:57Z" +pip = "2026-07-29T21:57:56Z" sqlfluff = "2026-05-15T00:00:00Z" [manifest] @@ -294,7 +294,7 @@ requires-dist = [ { name = "mypy", specifier = ">=1.19.1" }, { name = "mypy-extensions", specifier = ">=1.1.0" }, { name = "pandas-stubs", specifier = ">=3.0.0.260204" }, - { name = "pip", specifier = ">=26.1" }, + { name = "pip", specifier = ">=26.2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pylint", specifier = ">=4.0.4" }, { name = "pyright", specifier = ">=1.1.408" }, @@ -820,11 +820,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.2" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/96/e6f8e9d9d7b9cc4457092712a7e919c3186aa2c2fa9ffed2c5d29cc947e8/pip-26.2.tar.gz", hash = "sha256:2d8542afcc84cdd8e846c2b36b2861fad1da376dd98f8e7113e9108a3c331690", size = 1848845, upload-time = "2026-07-29T21:57:56.407Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, + { url = "https://files.pythonhosted.org/packages/62/36/a3aed958d60531cb442b7ab4596cda7b3621cfb916f8ae1d6769795c7dc1/pip-26.2-py3-none-any.whl", hash = "sha256:931c303696af6fa3417112103b1cad26890e5a07eccb5b99783700e33f2b8aad", size = 1816475, upload-time = "2026-07-29T21:57:54.763Z" }, ] [[package]] From 97504e824603345b397aaaf52947ab4fc5798e1e Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 17:59:44 -0600 Subject: [PATCH 16/30] fix(code-intel): make hook refresh exact and build-free --- Makefile | 34 +-- README.md | 25 +- TO_MY_PARENT.md | 17 +- docs/CODE_INTEL_STORAGE.md | 4 + go/cmd/coding-ethos-run/args.go | 2 +- go/cmd/coding-ethos-run/dispatch.go | 5 + go/cmd/coding-ethos-run/main_test.go | 162 ++++-------- go/cmd/coding-ethos-run/parent_workflow.go | 119 +++------ go/internal/codeintel/deleted_files.go | 18 +- go/internal/codeintel/maintenance.go | 236 ++++++++++++++++-- go/internal/codeintel/maintenance_test.go | 198 +++++++++++++++ go/internal/githookcli/main.go | 88 ++++++- go/internal/githookcli/main_internal_test.go | 38 +++ go/internal/hooklog/runner.go | 58 +---- go/internal/hooklog/runner_internal_test.go | 51 ---- go/internal/hooklog/runner_test.go | 133 ++++++---- go/internal/hookrunnercli/git_hook.go | 132 +++++++++- .../hookrunnercli/git_hook_internal_test.go | 32 +++ go/internal/hookrunnercli/toolchain_groups.go | 1 + go/internal/lintcli/main.go | 7 +- go/internal/lintcli/main_internal_test.go | 60 +++++ go/internal/managedcapture/capture.go | 4 + go/internal/managedcapture/capture_log.go | 2 - go/internal/managedcapture/capture_test.go | 59 +++++ tests/test_makefile_contract_diagnostics.py | 33 +++ 25 files changed, 1088 insertions(+), 430 deletions(-) create mode 100644 go/internal/codeintel/maintenance_test.go delete mode 100644 go/internal/hooklog/runner_internal_test.go diff --git a/Makefile b/Makefile index 75634d51..06ea7b27 100644 --- a/Makefile +++ b/Makefile @@ -194,15 +194,6 @@ define print_kv printf ' $(COLOR_ACCENT)%-24s$(COLOR_RESET) %s\n' "$(1)" "$(2)" endef -define quiet_build -tmp="$$(mktemp)"; \ -trap 'rm -f "$$tmp"' EXIT; \ -if ! $(MAKE) --no-print-directory build >"$$tmp" 2>&1; then \ - cat "$$tmp" >&2; \ - exit 1; \ -fi -endef - .PHONY: \ help \ status \ @@ -392,16 +383,13 @@ install-runtime: ensure-uv ## Sync only the runtime dependencies. @$(MAKE) sync-tool-configs @$(MAKE) sync-gemini-prompts -parent-install: ensure-go ## Sync parent repo coding-ethos artifacts with TOON output. - @$(quiet_build) +parent-install: ensure-hook-runtime ## Sync parent repo coding-ethos artifacts with TOON output. @"$(GO_HOOK)" parent-install --repo "$(HOOK_CONSUMER_ROOT)" -parent-check: ensure-go ## Verify parent repo coding-ethos artifacts with TOON output. - @$(quiet_build) +parent-check: ensure-hook-runtime ## Verify parent repo coding-ethos artifacts with TOON output. @"$(GO_HOOK)" parent-check --repo "$(HOOK_CONSUMER_ROOT)" -parent-lint: ensure-go ## Sync and lint the parent repo with TOON output. - @$(quiet_build) +parent-lint: ensure-hook-runtime ## Sync and lint the parent repo with TOON output. @"$(GO_HOOK)" parent-lint --repo "$(HOOK_CONSUMER_ROOT)" upgrade: upgrade-parent-submodule build parent-install parent-check parent-lint cutover-verify ## Fully upgrade a parent repo coding-ethos submodule and verify the result. @@ -588,7 +576,7 @@ sync-provider-matrix: ensure-go go-tools-install ## Generate the provider capabi @"$(GO_TOOLS_BIN_DIR)/coding-ethos-agent-hooks" \ sync-provider-matrix --root "$(LOCAL_REPO_ROOT)" -check-provider-matrix: ensure-go go-tools-install ## Fail if the provider capability matrix is out of sync. +check-provider-matrix: ensure-hook-runtime ## Fail if the provider capability matrix is out of sync. @$(call print_step,Checking provider capability matrix) @$(call print_info,repo: $(LOCAL_REPO_ROOT)) @"$(GO_TOOLS_BIN_DIR)/coding-ethos-agent-hooks" \ @@ -711,23 +699,23 @@ cutover-install: build ## Install Git and agent hooks, then verify cutover readi @$(call print_step,Installing and verifying repo-local hook cutover) @"$(GO_HOOK)" cutover install -cutover-verify: build ## Verify Git and agent hook cutover readiness. +cutover-verify: ensure-hook-runtime ## Verify Git and agent hook cutover readiness. @$(call print_step,Verifying repo-local hook cutover) @"$(GO_HOOK)" cutover verify -pre-commit: build ## Run bundled pre-commit hooks on staged files. +pre-commit: ensure-hook-runtime ## Run bundled pre-commit hooks on staged files. @$(call print_step,Running Go pre-commit hooks on staged files) @"$(GO_HOOK)" git-hook pre-commit -pre-commit-all: build ## Run bundled pre-commit hooks on all files. +pre-commit-all: ensure-hook-runtime ## Run bundled pre-commit hooks on all files. @$(call print_step,Running Go pre-commit hooks on all files) @"$(GO_HOOK)" git-hook pre-commit --all-files -pre-push: build ## Run bundled pre-push hooks. +pre-push: ensure-hook-runtime ## Run bundled pre-push hooks. @$(call print_step,Running Go pre-push hooks) @"$(GO_HOOK)" git-hook pre-push -commit-msg: build ## Run commit-message hooks against MSG=/path/to/file. +commit-msg: ensure-hook-runtime ## Run commit-message hooks against MSG=/path/to/file. ifndef MSG @printf '$(COLOR_WARN)Usage: make commit-msg MSG=/path/to/commit-message-file$(COLOR_RESET)\n' >&2 @exit 2 @@ -736,11 +724,11 @@ else @"$(GO_HOOK)" git-hook commit-msg "$(MSG)" endif -hook-plan: build ## Print the active Go hook group plan. +hook-plan: ensure-hook-runtime ## Print the active Go hook group plan. @$(call print_step,Printing Go hook group plan) @"$(GO_HOOK)" hook-plan -validate: build ## Validate the bundled hook runtime. +validate: ensure-hook-runtime ## Validate the bundled hook runtime. @$(call print_step,Validating bundled hook runtime) @"$(GO_HOOK)" git-hook validate diff --git a/README.md b/README.md index 0ed61ef2..47a4df0e 100644 --- a/README.md +++ b/README.md @@ -920,6 +920,7 @@ Make as a thin alias: ```bash coding-ethos/bin/coding-ethos-run parent-install +coding-ethos/bin/coding-ethos-run parent-runtime-sync coding-ethos/bin/coding-ethos-run parent-check coding-ethos/bin/coding-ethos-run parent-lint ``` @@ -935,13 +936,23 @@ artifact contract. projects byte-identical executables into the parent repository's stable common Git runtime. `parent-check` hashes both sides and fails if that projection is missing, non-executable, symlinked back to a retiring checkout, or stale. - -Parent install and check are artifact workflows and do not perform a full -repository code-intel refresh. Lint and Git-hook workflows refresh code intel -when source analysis is actually part of the requested gate, so deploying or -checking the runtime cannot be delayed by an unrelated whole-repository scan. -Failed or policy-blocked hooks retain their trace evidence but do not perform a -whole-repository refresh, because no accepted source transition occurred. +`parent-runtime-sync` is the supervisor-safe repair path: it projects the +already-built authoritative executables without rebuilding tools, rewriting +generated parent artifacts, or refreshing code intelligence. + +Builds are explicit maintenance. Diagnostic, validation, lint, commit, push, +and parent-check targets require an existing runtime and fail with a `make +build` instruction when it is absent; they never compile or install tools as a +side effect. Parent install and sync workflows operate through the already +built runner, so an authority source change must be built deliberately before +it is projected. + +Parent install, check, lint, and Git-hook workflows never perform a +whole-repository code-intel refresh. Normal hooks append their bounded trace +evidence, while managed lint and capture paths incrementally index only the +changed files already selected by the gate. Whole-repository refreshes and +derived-index rebuilds are explicit maintenance operations; allowed and +policy-blocked hooks never trigger them as a side effect. Consumer commits may include generated tool and provider configuration only when the staged Git-index bytes exactly match output rendered by the active diff --git a/TO_MY_PARENT.md b/TO_MY_PARENT.md index 6a9ec9d4..1fdc38ce 100644 --- a/TO_MY_PARENT.md +++ b/TO_MY_PARENT.md @@ -12,6 +12,7 @@ Prefer the Go runner as the stable interface: ```sh coding-ethos/bin/coding-ethos-run parent-install +coding-ethos/bin/coding-ethos-run parent-runtime-sync coding-ethos/bin/coding-ethos-run parent-check coding-ethos/bin/coding-ethos-run parent-lint ``` @@ -47,10 +48,18 @@ Explicit `repo_config.yaml` settings override these profile defaults. `parent-install` syncs generated parent artifacts and atomically refreshes the compiled executables in the parent repository's common -`.git/coding-ethos-hooks/bin/` runtime. `parent-check` verifies those artifacts -without rewriting them, including byte identity and executable independence -from any retiring worktree. `parent-lint` syncs the parent artifacts, then runs -the full parent lint scope through the compiled policy bundle. +`.git/coding-ethos-hooks/bin/` runtime. `parent-runtime-sync` only projects the +already-built authoritative executables into that runtime; it does not rebuild +tools, rewrite generated parent artifacts, or refresh code intelligence. It is +the narrow interface for supervisors repairing a stale shared runtime. +`parent-check` verifies those artifacts without rewriting them, including byte +identity and executable independence from any retiring worktree. `parent-lint` +syncs the parent artifacts, then runs the full parent lint scope through the +compiled policy bundle. + +These commands execute the already-built authority. They do not silently run +`make build`; after changing or updating authority source, build it explicitly +before installing, checking, linting, or projecting it. ## Output Contract diff --git a/docs/CODE_INTEL_STORAGE.md b/docs/CODE_INTEL_STORAGE.md index 6a9793bd..6b62a749 100644 --- a/docs/CODE_INTEL_STORAGE.md +++ b/docs/CODE_INTEL_STORAGE.md @@ -43,6 +43,10 @@ rebuildable analytical surface. - `code-intel sync` creates or reuses an exact v2 source generation without deleting or rewriting the v1 store. +- Normal hook, lint, commit, and push paths never rebuild or refresh the whole + repository. They append bounded event evidence and incrementally index only + files already selected by managed capture. Whole-repository work requires an + explicit code-intel maintenance command. - Eligible RDF or SPARQL requires an identity-validated pinned PurRDF helper; missing or mismatched helpers fail sync instead of degrading silently. - `code-intel status` compares the recorded source snapshot to the current diff --git a/go/cmd/coding-ethos-run/args.go b/go/cmd/coding-ethos-run/args.go index ad9501d5..226e205d 100644 --- a/go/cmd/coding-ethos-run/args.go +++ b/go/cmd/coding-ethos-run/args.go @@ -268,7 +268,7 @@ func (paths runtimePaths) withCommandRoots(args []string) runtimePaths { repoRoot = flagValue(args[1:], "--root", paths.Root) case "runtime-policy": repoRoot = flagValue(args[1:], "--repo", paths.Root) - case "parent-install", "parent-check", "parent-lint": + case "parent-install", "parent-runtime-sync", "parent-check", "parent-lint": repoRoot = flagValue(args[1:], "--repo", paths.Root) default: return paths diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index fc423463..2b9a9480 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -105,6 +105,7 @@ func runCommandEntries() []runCommandEntry { {Command: "policy-tool-group", Handler: runPolicyToolGroup}, {Command: "policy-git", Handler: runPolicyGitHandler}, {Command: "parent-install", Handler: runParentInstall}, + {Command: "parent-runtime-sync", Handler: runParentRuntimeSync}, {Command: "parent-check", Handler: runParentCheck}, {Command: "parent-lint", Handler: runParentLint}, {Command: "runtime-policy", Handler: runRuntimePolicy}, @@ -163,6 +164,10 @@ func runHelpMessage() feedback.Message { "runtime-policy sync|check --repo ", "Manage only the consumer-scoped hook policy bundle.", }, + { + "parent-runtime-sync --repo ", + "Project prebuilt hook executables into a consumer Git runtime.", + }, {"git-hook", "Git hook entrypoint; not for manual lint."}, }, ), diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index f5d43aa8..9f22a71d 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -406,6 +406,16 @@ func TestWithCommandRootsUsesCommandSpecificRepositoryFlags(t *testing.T) { "/private/state", }, }, + { + name: "parent runtime sync", + args: []string{ + "parent-runtime-sync", + "--repo", + "/repo", + "--state-root", + "/private/state", + }, + }, { name: "parent check", args: []string{ @@ -630,9 +640,10 @@ func TestParentCommandsRejectInvalidFlags(t *testing.T) { paths := runtimePaths{Root: "/repo"} for name, runCommand := range map[string]func(runtimePaths, []string) error{ - "parent-install": runParentInstall, - "parent-check": runParentCheck, - "parent-lint": runParentLint, + "parent-install": runParentInstall, + "parent-runtime-sync": runParentRuntimeSync, + "parent-check": runParentCheck, + "parent-lint": runParentLint, } { err := runCommand(paths, []string{"--missing-flag"}) if err == nil || !strings.Contains(err.Error(), "parse "+name+" flags") { @@ -783,14 +794,48 @@ func TestParentGoToolsCheckFailsWhenBinaryIsStale(t *testing.T) { touchParentGoTools(t, paths, oldTime) writeParentGoSource(t, sourceRoot, "internal/runtime/source.go", newTime) - err := checkParentGoTools(paths, parentWorkflowOptions{Repo: paths.Root}) + err := checkParentGoTools(paths) if err == nil || !strings.Contains(err.Error(), "parent Go tools are stale") || + !strings.Contains(err.Error(), "make build") || !strings.Contains(err.Error(), "coding-ethos-run(stale)") { t.Fatalf("checkParentGoTools error = %v", err) } } +func TestParentArtifactSyncRefusesToRebuildStaleTools(t *testing.T) { + t.Parallel() + + paths := runtimeTestPaths(t) + sourceRoot := parentGoToolsSourceFixture(t, paths.EthosRoot) + paths.ToolsSource = sourceRoot + + oldTime := time.Now().Add(-2 * time.Hour) + newTime := time.Now().Add(-1 * time.Hour) + touchParentGoTools(t, paths, oldTime) + writeParentGoSource(t, sourceRoot, "internal/runtime/source.go", newTime) + + toolPath := filepath.Join(paths.BinDir, "coding-ethos-run") + before, err := os.ReadFile(toolPath) + if err != nil { + t.Fatalf("read tool before sync: %v", err) + } + + steps := syncParentArtifacts(paths, parentWorkflowOptions{Repo: paths.Root}) + if len(steps) != 1 || steps[0].Name != "go_tools" || + steps[0].Status != parentStepFail { + t.Fatalf("sync steps = %#v, want one failed go_tools check", steps) + } + + after, err := os.ReadFile(toolPath) + if err != nil { + t.Fatalf("read tool after sync: %v", err) + } + if string(after) != string(before) { + t.Fatal("parent artifact sync rebuilt a stale executable") + } +} + func TestParentGoToolsCheckPassesWhenBinariesAreCurrent(t *testing.T) { t.Parallel() @@ -804,7 +849,7 @@ func TestParentGoToolsCheckPassesWhenBinariesAreCurrent(t *testing.T) { writeParentGoSource(t, sourceRoot, "internal/runtime/source.go", oldTime) touchParentGoTools(t, paths, newTime) - err := checkParentGoTools(paths, parentWorkflowOptions{Repo: paths.Root}) + err := checkParentGoTools(paths) if err != nil { t.Fatalf("checkParentGoTools: %v", err) } @@ -1001,73 +1046,6 @@ repo: } } -func TestRebuildParentGoToolsBuildsRepoLocalBinaries(t *testing.T) { - t.Parallel() - - paths := runtimeTestPaths(t) - sourceRoot := parentBuildableGoToolsSourceFixture(t, paths.EthosRoot) - paths.ToolsSource = sourceRoot - - err := rebuildParentGoTools(paths) - if err != nil { - t.Fatalf("rebuildParentGoTools: %v", err) - } - - tools, err := parentGoToolCommands(paths) - if err != nil { - t.Fatalf("parentGoToolCommands: %v", err) - } - - for _, tool := range tools { - toolPath := filepath.Join(paths.BinDir, tool) - - info, err := os.Stat(toolPath) - if err != nil { - t.Fatalf("stat rebuilt tool %s: %v", tool, err) - } - - if info.IsDir() || info.Mode()&0o111 == 0 { - t.Fatalf("rebuilt tool %s not executable: %#v", tool, info.Mode()) - } - } -} - -func TestBuildParentGoToolKeepsExistingBinaryWhenBuildFails(t *testing.T) { - t.Parallel() - - paths := runtimeTestPaths(t) - sourceRoot := parentBuildableGoToolsSourceFixture(t, paths.EthosRoot) - paths.ToolsSource = sourceRoot - - tool := "coding-ethos-run" - toolPath := filepath.Join(paths.BinDir, tool) - original := "#!/usr/bin/env sh\necho original\n" - writeExecutableFixture(t, toolPath, original) - - err := os.WriteFile( - filepath.Join(sourceRoot, "cmd", tool, "main.go"), - []byte("package main\n\nfunc main(\n"), - 0o600, - ) - if err != nil { - t.Fatalf("write invalid main: %v", err) - } - - err = buildParentGoTool(paths, tool) - if err == nil { - t.Fatalf("buildParentGoTool succeeded with invalid source") - } - - current, err := os.ReadFile(toolPath) - if err != nil { - t.Fatalf("read existing tool: %v", err) - } - - if string(current) != original { - t.Fatalf("existing tool changed after failed build: %q", string(current)) - } -} - func TestRequireParentGoToolsSourceRejectsFile(t *testing.T) { t.Parallel() @@ -3918,46 +3896,6 @@ func parentGoToolsSourceFixture(t *testing.T, ethosRoot string) string { return sourceRoot } -func parentBuildableGoToolsSourceFixture(t *testing.T, ethosRoot string) string { - t.Helper() - - sourceRoot := filepath.Join(ethosRoot, "go") - - err := os.MkdirAll(sourceRoot, 0o755) - if err != nil { - t.Fatalf("create Go source root: %v", err) - } - - err = os.WriteFile( - filepath.Join(sourceRoot, "go.mod"), - []byte("module example.test/coding-ethos\n\ngo 1.26\n"), - 0o600, - ) - if err != nil { - t.Fatalf("write go.mod: %v", err) - } - - for _, tool := range parentGoToolFixtureCommands() { - mainPath := filepath.Join(sourceRoot, "cmd", tool, "main.go") - - err = os.MkdirAll(filepath.Dir(mainPath), 0o755) - if err != nil { - t.Fatalf("create source dir: %v", err) - } - - err = os.WriteFile( - mainPath, - []byte("package main\n\nfunc main() {}\n"), - 0o600, - ) - if err != nil { - t.Fatalf("write source: %v", err) - } - } - - return sourceRoot -} - func assertProtectedBranchWorkPoliciesPresent(t *testing.T, bundlePath string) { t.Helper() diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index 77619275..4487e583 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -4,7 +4,6 @@ package main import ( - "context" "crypto/sha256" "errors" "flag" @@ -25,7 +24,6 @@ import ( "blackcat.ca/coding-ethos/go/internal/hookoutput" "blackcat.ca/coding-ethos/go/internal/policy" "blackcat.ca/coding-ethos/go/internal/repoignore" - "blackcat.ca/coding-ethos/go/internal/safeexec" "blackcat.ca/coding-ethos/go/internal/shellquote" "blackcat.ca/coding-ethos/go/internal/toolconfigs" ) @@ -83,6 +81,26 @@ func runParentInstall(paths runtimePaths, rest []string) error { return nil } +func runParentRuntimeSync(paths runtimePaths, rest []string) error { + options, err := parseParentWorkflowFlags(paths, "parent-runtime-sync", rest) + if err != nil { + return err + } + + steps := []parentWorkflowStep{runParentStep("hook_runtime", func() error { + return syncParentHookRuntimeExecutables(paths, options) + })} + printParentWorkflowReport( + "parent-runtime-sync", + parentStepStatus(steps), + options.Repo, + steps, + ) + exitForFailedParentSteps(steps) + + return nil +} + func runParentCheck(paths runtimePaths, rest []string) error { options, err := parseParentWorkflowFlags(paths, "parent-check", rest) if err != nil { @@ -240,9 +258,14 @@ func syncParentArtifacts( options parentWorkflowOptions, ) []parentWorkflowStep { steps := []parentWorkflowStep{} + steps = append(steps, runParentStep("go_tools", func() error { - return rebuildParentGoTools(paths) + return checkParentGoTools(paths) })) + if parentStepStatus(steps) != parentStepPass { + return steps + } + steps = append(steps, runParentStep("hook_runtime", func() error { return syncParentHookRuntimeExecutables(paths, options) })) @@ -301,7 +324,7 @@ func checkParentArtifacts( ) []parentWorkflowStep { steps := []parentWorkflowStep{} steps = append(steps, runParentStep("go_tools", func() error { - return checkParentGoTools(paths, options) + return checkParentGoTools(paths) })) steps = append(steps, runParentStep("hook_runtime", func() error { return checkParentHookRuntimeExecutables(paths, options) @@ -799,89 +822,7 @@ func firstNonEmptyPath(values ...string) string { return "" } -func rebuildParentGoTools(paths runtimePaths) error { - err := requireParentGoToolsSource(paths) - if err != nil { - return err - } - - err = os.MkdirAll(paths.BinDir, parentExecutableDirMode) - if err != nil { - return fmt.Errorf("create Go tool bin dir: %w", err) - } - - tools, err := parentGoToolCommands(paths) - if err != nil { - return err - } - - for _, tool := range tools { - err = buildParentGoTool(paths, tool) - if err != nil { - return err - } - } - - return nil -} - -func buildParentGoTool(paths runtimePaths, tool string) error { - outputPath := filepath.Join(paths.BinDir, tool) - - tempFile, err := os.CreateTemp(paths.BinDir, "."+tool+"-*.tmp") - if err != nil { - return fmt.Errorf("create temporary binary for %s: %w", tool, err) - } - - tempPath := tempFile.Name() - - err = tempFile.Close() - if err != nil { - return fmt.Errorf("close temporary binary for %s: %w", tool, err) - } - - err = os.Remove(tempPath) - if err != nil { - return fmt.Errorf("prepare temporary binary for %s: %w", tool, err) - } - - defer func() { - _ = os.Remove(tempPath) - }() - - command := safeexec.CommandContext( - context.Background(), - "go", - "build", - "-trimpath", - "-buildvcs=false", - "-o", - tempPath, - "./cmd/"+tool, - ) - command.Dir = paths.ToolsSource - - command.Env = append(os.Environ(), "GOWORK=off") - - output, err := command.CombinedOutput() - if err != nil { - detail := strings.TrimSpace(string(output)) - if detail != "" { - return fmt.Errorf("build %s: %w: %s", tool, err, detail) - } - - return fmt.Errorf("build %s: %w", tool, err) - } - - err = os.Rename(tempPath, outputPath) - if err != nil { - return fmt.Errorf("install %s: %w", tool, err) - } - - return nil -} - -func checkParentGoTools(paths runtimePaths, options parentWorkflowOptions) error { +func checkParentGoTools(paths runtimePaths) error { err := requireParentGoToolsSource(paths) if err != nil { return err @@ -925,9 +866,9 @@ func checkParentGoTools(paths runtimePaths, options parentWorkflowOptions) error } return fmt.Errorf( - "%w: run %s; tools: %s", + "%w: run `make build` in %s before parent workflows; tools: %s", errParentGoToolsStale, - parentInstallCommand(options), + paths.EthosRoot, strings.Join(stale, " "), ) } diff --git a/go/internal/codeintel/deleted_files.go b/go/internal/codeintel/deleted_files.go index f56fda54..03d849cd 100644 --- a/go/internal/codeintel/deleted_files.go +++ b/go/internal/codeintel/deleted_files.go @@ -20,6 +20,7 @@ import ( const ( codeFileStaleDeleted = "deleted" codeFileStaleDeletedByIntent = "deleted_by_intent" + gitStagedDeleteArgCount = 8 ) func (store *Store) MarkMissingCodeFilesDeleted( @@ -275,9 +276,16 @@ func gitDeletedPaths(ctx context.Context, root string) map[string]bool { } func gitStagedDeletedPaths(ctx context.Context, root string) map[string]bool { - command := realgit.Command( - ctx, - false, + return gitStagedDeletedPathsFor(ctx, root, nil) +} + +func gitStagedDeletedPathsFor( + ctx context.Context, + root string, + paths []string, +) map[string]bool { + args := make([]string, 0, gitStagedDeleteArgCount+len(paths)) + args = append(args, "-C", root, "diff", @@ -285,7 +293,11 @@ func gitStagedDeletedPaths(ctx context.Context, root string) map[string]bool { "--cached", "--diff-filter=D", "-z", + "--", ) + args = append(args, paths...) + + command := realgit.Command(ctx, false, args...) command.Env = realgit.CleanGitLocalEnv(os.Environ()) output, err := command.Output() diff --git a/go/internal/codeintel/maintenance.go b/go/internal/codeintel/maintenance.go index a9b3de8c..985d4d13 100644 --- a/go/internal/codeintel/maintenance.go +++ b/go/internal/codeintel/maintenance.go @@ -8,7 +8,9 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" + "time" ) // MaintenanceSummary reports repo-local code-intel refresh work. @@ -18,8 +20,10 @@ type MaintenanceSummary struct { DiffPatterns DiffEditPatternSummary `json:"diff_patterns"` } -// RefreshRepository opens the repo-local code-intel store, ingests retained -// traces, and refreshes AST facts for the requested paths. +// RefreshRepository explicitly opens the repo-local code-intel store, ingests +// retained traces, and refreshes AST facts for the requested paths. Normal +// hook execution must use the event and changed-file ingestion paths instead; +// a whole-repository refresh is an operator-requested maintenance action. func RefreshRepository( ctx context.Context, root string, @@ -58,41 +62,228 @@ func RefreshRepository( }, nil } -// RefreshLintFiles refreshes code-intel AST facts for lint-scoped paths. +// RefreshLintFiles incrementally refreshes code-intel AST facts for explicit +// lint-scoped files and tombstones explicit files that have been deleted. It +// rejects directories and out-of-repository paths so ordinary hook execution +// cannot turn this API into a whole-repository walk. func RefreshLintFiles( ctx context.Context, root string, paths []string, ) (CodeIndexSummary, error) { - paths = existingLintIndexPaths(root, paths) - if len(paths) == 0 { + selected := selectLintIndexPaths(root, paths) + if len(selected.requested) == 0 { return CodeIndexSummary{}, nil } + root, err := filepath.Abs(root) + if err != nil { + return CodeIndexSummary{}, fmt.Errorf("resolve lint index root: %w", err) + } + store, err := Open(ctx, DefaultDBPath(ResolveStateRoot(root))) if err != nil { return CodeIndexSummary{}, err } defer store.Close() - summary, err := NewASTIndexer(store).IndexPaths(ctx, root, paths) + existingFiles, err := explicitCodeFilesByPath(ctx, store, selected.requested) if err != nil { - return CodeIndexSummary{}, fmt.Errorf("refresh code-intel lint files: %w", err) + return CodeIndexSummary{}, err } - _, err = store.RefreshDiffEditPatterns(ctx, root) + summary, ignored, err := indexExplicitLintFiles( + ctx, + store, + root, + selected.existing, + existingFiles, + ) if err != nil { return CodeIndexSummary{}, fmt.Errorf( - "refresh code-intel diff edit patterns: %w", + "refresh code-intel lint files: %w", + err, + ) + } + + deleted := activeExplicitCodeFiles(existingFiles, selected.deleted) + if len(deleted) > 0 { + err = store.markDeletedCodeFiles( + ctx, + root, + deleted, + gitStagedDeletedPathsFor(ctx, root, deleted), + ) + if err != nil { + return CodeIndexSummary{}, fmt.Errorf( + "tombstone deleted code-intel lint files: %w", + err, + ) + } + } + + err = markExplicitCodeFilesInactive(ctx, store, ignored, "ignored") + if err != nil { + return CodeIndexSummary{}, fmt.Errorf( + "tombstone ignored code-intel lint files: %w", err, ) } + summary.Deleted = append(summary.Deleted, deleted...) + summary.Deleted = append(summary.Deleted, ignored...) + slices.Sort(summary.Deleted) + summary.Deleted = slices.Compact(summary.Deleted) + return summary, nil } -func existingLintIndexPaths(root string, paths []string) []string { - selected := []string{} +func explicitCodeFilesByPath( + ctx context.Context, + store *Store, + paths []string, +) (map[string]CodeFile, error) { + files := make(map[string]CodeFile, len(paths)) + + for _, path := range paths { + file, found, err := store.GetCodeFile(ctx, path) + if err != nil { + return nil, err + } + + if found { + files[path] = file + } + } + + return files, nil +} + +func indexExplicitLintFiles( + ctx context.Context, + store *Store, + root string, + paths []string, + existingFiles map[string]CodeFile, +) (CodeIndexSummary, []string, error) { + options, err := LoadIndexOptions(root) + if err != nil { + return CodeIndexSummary{}, nil, err + } + + ignoreMatcher := gitIgnoreMatcher{ + root: root, + active: gitWorkTreeAvailable(ctx, root), + } + indexer := NewASTIndexer(store) + summary := CodeIndexSummary{} + ignored := []string{} + + for _, path := range paths { + absolutePath := filepath.Join(root, filepath.FromSlash(path)) + + info, statErr := os.Lstat(absolutePath) + if statErr != nil { + if os.IsNotExist(statErr) { + continue + } + + return CodeIndexSummary{}, nil, fmt.Errorf( + "stat lint index file %q: %w", + path, + statErr, + ) + } + + if !info.Mode().IsRegular() { + continue + } + + if ignoreMatcher.ignoredFile(ctx, absolutePath) || + pathHasSkippedDir(path) || + excludedByConfig(path, options.ExcludePatterns) { + if existing, found := existingFiles[path]; found && + existing.DeletedAtUTC == "" { + ignored = append(ignored, path) + } + + continue + } + + err = indexer.indexFile( + ctx, + root, + absolutePath, + info, + gitIgnoreMatcher{}, + existingFiles, + options, + &summary, + ) + if err != nil { + return CodeIndexSummary{}, nil, err + } + } + + return summary, ignored, nil +} + +func activeExplicitCodeFiles( + existingFiles map[string]CodeFile, + paths []string, +) []string { + active := make([]string, 0, len(paths)) + + for _, path := range paths { + if existing, found := existingFiles[path]; found && existing.DeletedAtUTC == "" { + active = append(active, path) + } + } + + return active +} + +func markExplicitCodeFilesInactive( + ctx context.Context, + store *Store, + paths []string, + reason string, +) error { + if len(paths) == 0 { + return nil + } + + inactiveAt := time.Now().UTC().Format(time.RFC3339) + + transaction, err := store.database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin explicit code file refresh: %w", err) + } + defer rollbackUnlessCommitted(transaction) + + for _, path := range paths { + err = markCodeFileInactive(ctx, transaction, path, inactiveAt, reason) + if err != nil { + return err + } + } + + err = transaction.Commit() + if err != nil { + return fmt.Errorf("commit explicit code file refresh: %w", err) + } + + return nil +} + +type lintIndexPathSelection struct { + requested []string + existing []string + deleted []string +} + +func selectLintIndexPaths(root string, paths []string) lintIndexPathSelection { + selected := lintIndexPathSelection{} seen := map[string]struct{}{} rootAbs, err := filepath.Abs(root) @@ -120,20 +311,33 @@ func existingLintIndexPaths(root string, paths []string) []string { continue } - info, err := os.Stat(statPath) - if err != nil || !info.Mode().IsRegular() { + if _, ok := seen[relative]; ok { continue } - if _, ok := seen[statPath]; ok { + info, statErr := os.Lstat(statPath) + if statErr != nil && !os.IsNotExist(statErr) { continue } - seen[statPath] = struct{}{} + if statErr == nil && !info.Mode().IsRegular() { + continue + } + + seen[relative] = struct{}{} + selected.requested = append(selected.requested, relative) - selected = append(selected, relative) + if statErr == nil { + selected.existing = append(selected.existing, relative) + } else { + selected.deleted = append(selected.deleted, relative) + } } + slices.Sort(selected.requested) + slices.Sort(selected.existing) + slices.Sort(selected.deleted) + return selected } diff --git a/go/internal/codeintel/maintenance_test.go b/go/internal/codeintel/maintenance_test.go new file mode 100644 index 00000000..4df6e999 --- /dev/null +++ b/go/internal/codeintel/maintenance_test.go @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package codeintel + +import ( + "context" + "os" + "path/filepath" + "slices" + "testing" + + "blackcat.ca/coding-ethos/go/internal/realgit" +) + +func TestRefreshLintFilesIndexesOnlyExplicitFilesAndTombstonesDeletion(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + appPath := filepath.Join(root, "pkg", "app.py") + otherPath := filepath.Join(root, "pkg", "other.py") + + writeMaintenanceTestFile(t, appPath, []byte("VALUE = 1\n")) + writeMaintenanceTestFile(t, otherPath, []byte("OTHER = 2\n")) + runMaintenanceTestGit(t, root, "init", "--initial-branch", "main") + runMaintenanceTestGit(t, root, "config", "user.email", "test@example.com") + runMaintenanceTestGit(t, root, "config", "user.name", "Test User") + runMaintenanceTestGit(t, root, "add", "pkg/app.py", "pkg/other.py") + runMaintenanceTestGit(t, root, "commit", "-m", "initial") + + summary, err := RefreshLintFiles(ctx, root, []string{"pkg/app.py"}) + if err != nil { + t.Fatalf("initial incremental refresh: %v", err) + } + if summary.FilesIndexed != 1 { + t.Fatalf("files indexed = %d, want 1", summary.FilesIndexed) + } + + store, err := Open(ctx, DefaultDBPath(root)) + if err != nil { + t.Fatalf("open code-intel store: %v", err) + } + + if _, found, getErr := store.GetCodeFile(ctx, "pkg/app.py"); getErr != nil || !found { + t.Fatalf("requested file found=%v err=%v, want true nil", found, getErr) + } + if file, found, getErr := store.GetCodeFile( + ctx, + "pkg/other.py", + ); getErr != nil || + found { + t.Fatalf("unrequested file=%#v found=%v err=%v, want absent", file, found, getErr) + } + if err = store.Close(); err != nil { + t.Fatalf("close code-intel store: %v", err) + } + + if err = os.Remove(appPath); err != nil { + t.Fatalf("delete indexed file: %v", err) + } + runMaintenanceTestGit(t, root, "add", "--update", "pkg/app.py") + + summary, err = RefreshLintFiles(ctx, root, []string{"pkg/app.py"}) + if err != nil { + t.Fatalf("deleted-file incremental refresh: %v", err) + } + if !slices.Contains(summary.Deleted, "pkg/app.py") { + t.Fatalf("deleted summary = %#v, want pkg/app.py", summary.Deleted) + } + + store, err = Open(ctx, DefaultDBPath(root)) + if err != nil { + t.Fatalf("reopen code-intel store: %v", err) + } + defer store.Close() + + file, found, err := store.GetCodeFile(ctx, "pkg/app.py") + if err != nil { + t.Fatalf("get deleted code file: %v", err) + } + if !found || file.DeletedAtUTC == "" || file.StaleReason != "deleted_by_intent" { + t.Fatalf("deleted file = %#v, found=%v", file, found) + } +} + +func TestRefreshLintFilesRejectsDirectoryAndOutsidePaths(t *testing.T) { + t.Parallel() + + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.py") + writeMaintenanceTestFile(t, filepath.Join(root, "app.py"), []byte("VALUE = 1\n")) + writeMaintenanceTestFile(t, outside, []byte("OUTSIDE = 1\n")) + symlink := filepath.Join(root, "outside-link.py") + if err := os.Symlink(outside, symlink); err != nil { + t.Fatalf("create outside symlink: %v", err) + } + + summary, err := RefreshLintFiles( + context.Background(), + root, + []string{".", outside, "outside-link.py"}, + ) + if err != nil { + t.Fatalf("reject broad lint paths: %v", err) + } + if summary.FilesIndexed != 0 || summary.ChunksIndexed != 0 || + len(summary.Skipped) != 0 || len(summary.Deleted) != 0 { + t.Fatalf("summary = %#v, want empty", summary) + } + + if _, err = os.Stat(DefaultDBPath(root)); !os.IsNotExist(err) { + t.Fatalf("ordinary broad paths created code-intel DB: %v", err) + } +} + +func TestRefreshLintFilesDoesNotReconcileUnrelatedIndexedFiles(t *testing.T) { + t.Parallel() + + ctx := context.Background() + root := t.TempDir() + appPath := filepath.Join(root, "app.py") + generatedPath := filepath.Join(root, "generated.py") + + writeMaintenanceTestFile(t, appPath, []byte("VALUE = 1\n")) + writeMaintenanceTestFile(t, generatedPath, []byte("GENERATED = 1\n")) + runMaintenanceTestGit(t, root, "init", "--initial-branch", "main") + runMaintenanceTestGit(t, root, "config", "user.email", "test@example.com") + runMaintenanceTestGit(t, root, "config", "user.name", "Test User") + runMaintenanceTestGit(t, root, "add", "app.py") + runMaintenanceTestGit(t, root, "commit", "-m", "initial") + + store, err := Open(ctx, DefaultDBPath(root)) + if err != nil { + t.Fatalf("open code-intel store: %v", err) + } + + _, err = NewASTIndexer(store).IndexPaths( + ctx, + root, + []string{"app.py", "generated.py"}, + ) + if err != nil { + t.Fatalf("seed code-intel index: %v", err) + } + if err = store.Close(); err != nil { + t.Fatalf("close seeded code-intel store: %v", err) + } + + writeMaintenanceTestFile( + t, + filepath.Join(root, ".gitignore"), + []byte("generated.py\n"), + ) + writeMaintenanceTestFile(t, appPath, []byte("VALUE = 2\n")) + + if _, err = RefreshLintFiles(ctx, root, []string{"app.py"}); err != nil { + t.Fatalf("incremental refresh: %v", err) + } + + store, err = Open(ctx, DefaultDBPath(root)) + if err != nil { + t.Fatalf("reopen code-intel store: %v", err) + } + defer store.Close() + + generated, found, err := store.GetCodeFile(ctx, "generated.py") + if err != nil { + t.Fatalf("get unrelated indexed file: %v", err) + } + if !found || generated.DeletedAtUTC != "" || generated.StaleReason != "" { + t.Fatalf("unrelated indexed file = %#v, found=%v, want active", generated, found) + } +} + +func writeMaintenanceTestFile(t *testing.T, path string, content []byte) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("create test file parent: %v", err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("write test file: %v", err) + } +} + +func runMaintenanceTestGit(t *testing.T, root string, args ...string) { + t.Helper() + + command := realgit.Command(context.Background(), false, args...) + command.Dir = root + command.Env = realgit.CleanGitLocalEnv(os.Environ()) + + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } +} diff --git a/go/internal/githookcli/main.go b/go/internal/githookcli/main.go index 5892f27b..e2d12778 100644 --- a/go/internal/githookcli/main.go +++ b/go/internal/githookcli/main.go @@ -4,6 +4,7 @@ package githookcli import ( + "bytes" "context" "flag" "fmt" @@ -17,6 +18,7 @@ import ( "blackcat.ca/coding-ethos/go/diagnostics" "blackcat.ca/coding-ethos/go/internal/apperror" + "blackcat.ca/coding-ethos/go/internal/codeintel" "blackcat.ca/coding-ethos/go/internal/evaluators" "blackcat.ca/coding-ethos/go/internal/feedback" "blackcat.ca/coding-ethos/go/internal/generatedtrust" @@ -42,6 +44,8 @@ const ( hookMessageFileIndex = 1 hookSourceIndex = 2 hookCommitIndex = 3 + gitDiffArgsCapacity = 4 + gitDiffNULCapacity = 5 ) var ( @@ -484,6 +488,15 @@ func runStagedHook(bundle policy.Bundle, cwd, hookName string) (int, bool) { code := policyResultExitCode(cwd, result) + codeIntelFiles, codeIntelErr := hookCodeIntelFiles(cwd, hookName) + if codeIntelErr != nil { + writeGitHookText( + "warning: staged code-intel paths not resolved: " + codeIntelErr.Error(), + ) + } else { + refreshGitHookCodeIntel(cwd, codeIntelFiles) + } + return code, code != 0 } @@ -537,13 +550,29 @@ func hookFiles(cwd, hookName string) ([]string, error) { return nil, nil } + return gitDiffFileNames(cwd, "--diff-filter=ACMR") +} + +func hookCodeIntelFiles(cwd, hookName string) ([]string, error) { + if hookName != hookPreCommit { + return nil, nil + } + + // Disable rename detection so the old path is reported as deleted and the + // new path as added. Incremental code-intel can then tombstone and index the + // two sides without a whole-repository scan. + return gitDiffFileNamesNUL(cwd, "--no-renames", "--diff-filter=ACDMR") +} + +func gitDiffFileNames(cwd string, options ...string) ([]string, error) { + args := make([]string, 0, gitDiffArgsCapacity+len(options)) + args = append(args, "diff", "--cached", "--name-only") + args = append(args, options...) + args = append(args, "--") + command := evaluators.GitCommand( cwd, - "diff", - "--cached", - "--name-only", - "--diff-filter=ACMR", - "--", + args..., ) output, err := command.Output() @@ -563,6 +592,48 @@ func hookFiles(cwd, hookName string) ([]string, error) { return files, nil } +func gitDiffFileNamesNUL(cwd string, options ...string) ([]string, error) { + args := make([]string, 0, gitDiffNULCapacity+len(options)) + args = append(args, "diff", "--cached", "--name-only", "-z") + args = append(args, options...) + args = append(args, "--") + + output, err := evaluators.GitCommand(cwd, args...).Output() + if err != nil { + return nil, fmt.Errorf("list staged code-intel files: %w", err) + } + + files := []string{} + seen := map[string]bool{} + + for rawPath := range bytes.SplitSeq(output, []byte{0}) { + if len(rawPath) == 0 { + continue + } + + path := string(rawPath) + if !seen[path] { + seen[path] = true + files = append(files, path) + } + } + + return files, nil +} + +func refreshGitHookCodeIntel(cwd string, files []string) { + if len(files) == 0 { + return + } + + _, err := codeintel.RefreshLintFiles(context.Background(), cwd, files) + if err != nil { + writeGitHookText( + "warning: staged files not incrementally indexed: " + err.Error(), + ) + } +} + func readBundle(path string) (policy.Bundle, error) { file, err := os.Open(path) if err != nil { @@ -598,6 +669,13 @@ func logLintResult(cwd string, result lint.Result) { writeGitHookText("warning: lint SARIF sidecar not written: " + inlineErrB.Error()) } + inlineErrC := codeintel.IngestLintTraceFile(context.Background(), cwd, tracePath) + if inlineErrC != nil { + writeGitHookText( + "warning: lint trace not ingested into code-intel: " + inlineErrC.Error(), + ) + } + err := outputsurface.AutoPruneSurface( context.Background(), cwd, diff --git a/go/internal/githookcli/main_internal_test.go b/go/internal/githookcli/main_internal_test.go index d2ebc16c..1507f46a 100644 --- a/go/internal/githookcli/main_internal_test.go +++ b/go/internal/githookcli/main_internal_test.go @@ -71,6 +71,36 @@ func TestHookFilesReturnsStagedPreCommitFiles(t *testing.T) { } } +func TestHookCodeIntelFilesIncludesDeletionAndBothRenameSides(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + runTestGit(t, repo, "init") + runTestGit(t, repo, "config", "user.email", "test@example.com") + runTestGit(t, repo, "config", "user.name", "Test User") + runTestGit(t, repo, "config", "commit.gpgsign", "false") + writeTestGitHookFile(t, repo, "old.py", "VALUE = 1\n") + writeTestGitHookFile(t, repo, "removed.py", "REMOVED = 1\n") + writeTestGitHookFile(t, repo, "line\nbreak.py", "ODD = 1\n") + runTestGit(t, repo, "add", "old.py", "removed.py", "line\nbreak.py") + runTestGit(t, repo, "commit", "-m", "initial") + runTestGit(t, repo, "mv", "old.py", "new.py") + runTestGit(t, repo, "rm", "removed.py") + writeTestGitHookFile(t, repo, "line\nbreak.py", "ODD = 2\n") + runTestGit(t, repo, "add", "line\nbreak.py") + + files, err := hookCodeIntelFiles(repo, hookPreCommit) + if err != nil { + t.Fatalf("code-intel hook files: %v", err) + } + + for _, want := range []string{"line\nbreak.py", "new.py", "old.py", "removed.py"} { + if !slices.Contains(files, want) { + t.Fatalf("code-intel files = %#v, missing %q", files, want) + } + } +} + func TestDirectRealGitCommitAllowsHumanGitThroughInstalledHooks(t *testing.T) { clearAgentGitHookEnv(t) @@ -155,6 +185,14 @@ func TestDirectAgentGitCommitBlocksThroughInstalledHooks(t *testing.T) { t.Fatalf("direct Codex git output missing %q:\n%s", want, output) } } + + if _, statErr := os.Stat( + filepath.Join(repo.root, ".coding-ethos", "code-intel.duckdb"), + ); !os.IsNotExist( + statErr, + ) { + t.Fatalf("direct wrapper denial created code-intel DB: %v", statErr) + } } func TestGitHookCommitAmendInAncestryHandlesIncompleteVerifier(t *testing.T) { diff --git a/go/internal/hooklog/runner.go b/go/internal/hooklog/runner.go index 6539ffa5..b21d31c7 100644 --- a/go/internal/hooklog/runner.go +++ b/go/internal/hooklog/runner.go @@ -167,7 +167,7 @@ func runWithStatus(options Options) (int, error) { return 1, metadataErr } - maintenanceErr := finishHookMaintenance(options, runDir, status) + maintenanceErr := finishHookMaintenance(options, runDir) return completedHookStatus(status, err, maintenanceErr) } @@ -199,8 +199,8 @@ func completedHookStatus(status int, runErr, maintenanceErr error) (int, error) return status, nil } -func finishHookMaintenance(options Options, runDir string, status int) error { - err := refreshCodeIntelAfterRun(options, runDir, status) +func finishHookMaintenance(options Options, runDir string) error { + err := ingestHookTraceAfterRun(options, runDir) if err != nil { return err } @@ -214,20 +214,7 @@ func finishHookMaintenance(options Options, runDir string, status int) error { return nil } -func refreshCodeIntelAfterRun(options Options, runDir string, status int) error { - if shouldRefreshRepository(options.Command, status) { - _, err := codeintel.RefreshRepository( - context.Background(), - options.Root, - []string{"."}, - ) - if err != nil { - return fmt.Errorf("refresh code-intel after hook run: %w", err) - } - - return nil - } - +func ingestHookTraceAfterRun(options Options, runDir string) error { tracePath := filepath.Join(runDir, "event.json") _, err := os.Stat(tracePath) @@ -247,10 +234,6 @@ func refreshCodeIntelAfterRun(options Options, runDir string, status int) error return nil } -func shouldRefreshRepository(command []string, status int) bool { - return status == 0 && shouldForceCodeIntelRefresh(command) -} - func autoPruneHookRuns(root string) error { err := outputsurface.AutoPruneSurface(context.Background(), root, "hook_runs", false) if err != nil { @@ -260,39 +243,6 @@ func autoPruneHookRuns(root string) error { return nil } -func shouldForceCodeIntelRefresh(command []string) bool { - // Parent workflows refresh their explicit --repo target as part of the - // workflow itself. Repeating that work here both doubles maintenance and, - // before command-root resolution, could index the invocation repository. - return commandContains(command, "policy-lint") || - commandContainsSequence(command, "git-hook", "pre-commit") || - commandContainsSequence(command, "git-hook", "pre-push") || - commandContainsSequence(command, "make", "pre-commit") || - commandContainsSequence(command, "make", "pre-commit-all") || - commandContainsSequence(command, "make", "check") || - commandContainsSequence(command, "make", "lint") -} - -func commandContains(command []string, value string) bool { - for _, part := range command { - if filepath.Base(part) == value || part == value { - return true - } - } - - return false -} - -func commandContainsSequence(command []string, first, second string) bool { - for index := 0; index+1 < len(command); index++ { - if filepath.Base(command[index]) == first && command[index+1] == second { - return true - } - } - - return false -} - func runLoggedPayload( options Options, logs hookRunLogs, diff --git a/go/internal/hooklog/runner_internal_test.go b/go/internal/hooklog/runner_internal_test.go deleted file mode 100644 index 693eb238..00000000 --- a/go/internal/hooklog/runner_internal_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. -// SPDX-License-Identifier: AGPL-3.0-only - -package hooklog - -import "testing" - -func TestShouldForceCodeIntelRefreshSkipsManagedToolRuns(t *testing.T) { - t.Parallel() - - for _, command := range [][]string{ - {"coding-ethos-run", "policy-tool", "go-test", "go"}, - {"coding-ethos-run", "policy-tool", "ruff", "check", "pkg"}, - {"coding-ethos-run", "policy-tool-group", "type_check"}, - {"coding-ethos-run", "parent-install", "--repo", "/repo"}, - {"coding-ethos-run", "parent-check", "--repo", "/repo"}, - {"coding-ethos-run", "parent-lint", "--repo", "/repo"}, - } { - if shouldForceCodeIntelRefresh(command) { - t.Fatalf("shouldForceCodeIntelRefresh(%#v) = true, want false", command) - } - } -} - -func TestShouldForceCodeIntelRefreshKeepsHookAndRepoGates(t *testing.T) { - t.Parallel() - - for _, command := range [][]string{ - {"coding-ethos-run", "policy-lint"}, - {"coding-ethos-run", "git-hook", "pre-commit"}, - {"coding-ethos-run", "git-hook", "pre-push"}, - {"make", "check"}, - {"make", "pre-commit"}, - } { - if !shouldForceCodeIntelRefresh(command) { - t.Fatalf("shouldForceCodeIntelRefresh(%#v) = false, want true", command) - } - } -} - -func TestShouldRefreshRepositorySkipsFailedHook(t *testing.T) { - t.Parallel() - - command := []string{"coding-ethos-run", "git-hook", "pre-commit"} - if !shouldRefreshRepository(command, 0) { - t.Fatal("successful pre-commit should refresh repository code-intel") - } - if shouldRefreshRepository(command, 2) { - t.Fatal("blocked pre-commit must not perform a full repository refresh") - } -} diff --git a/go/internal/hooklog/runner_test.go b/go/internal/hooklog/runner_test.go index 28468aa4..97b3c8a8 100644 --- a/go/internal/hooklog/runner_test.go +++ b/go/internal/hooklog/runner_test.go @@ -6,6 +6,7 @@ package hooklog_test import ( "bytes" "errors" + "fmt" "os" "path/filepath" "strings" @@ -178,7 +179,7 @@ func TestRunChecksIgnoresWithoutIndex(t *testing.T) { root := t.TempDir() writeHookLogIgnore(t, root) - logPath := filepath.Join(t.TempDir(), "git-args.log") + logPath := filepath.Join(root, "git-args.log") git := fakeGitWithLog(t, logPath) err := Run(Options{ @@ -264,60 +265,94 @@ func TestRunIngestsHookTraceIntoCodeIntel(t *testing.T) { } } -func TestRunForcesCodeIntelRefreshForPreCommit(t *testing.T) { - t.Parallel() +func TestRunIngestsPreCommitTraceWithoutRepositoryRefresh(t *testing.T) { testlock.ProcessState(t, "hooklog-global-streams") - root := t.TempDir() - writeHookLogIgnore(t, root) - - err := os.WriteFile( - filepath.Join(root, "app.js"), - []byte("export function run() { return 1; }\n"), - 0o600, - ) - if err != nil { - t.Fatalf("write source: %v", err) - } - - status, err := RunInProcess(Options{ - Stdin: strings.NewReader(""), - Stdout: &bytes.Buffer{}, - Stderr: &bytes.Buffer{}, - GitPath: fakeGit(t), - Root: root, - BundleRoot: filepath.Join(root, "pre-commit"), - Command: []string{"coding-ethos-run", "git-hook", "pre-commit"}, - Now: func() time.Time { - return time.Date(2026, 5, 1, 12, 34, 56, 0, time.UTC) - }, - }, func() int { - return 0 - }) - if err != nil { - t.Fatalf("RunInProcess returned error: %v", err) - } + for _, testCase := range []struct { + name string + status int + }{ + {name: "allowed", status: 0}, + {name: "blocked", status: 2}, + } { + t.Run(testCase.name, func(t *testing.T) { + root := t.TempDir() + writeHookLogIgnore(t, root) + + err := os.WriteFile( + filepath.Join(root, "app.js"), + []byte("export function run() { return 1; }\n"), + 0o600, + ) + if err != nil { + t.Fatalf("write source: %v", err) + } - if status != 0 { - t.Fatalf("status = %d, want 0", status) - } + status, err := RunInProcess(Options{ + Stdin: strings.NewReader(""), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + GitPath: fakeGit(t), + Root: root, + BundleRoot: filepath.Join(root, "pre-commit"), + Command: []string{"coding-ethos-run", "git-hook", "pre-commit"}, + Now: func() time.Time { + return time.Date(2026, 5, 1, 12, 34, 56, 0, time.UTC) + }, + }, func() int { + tracePath := filepath.Join( + os.Getenv("CODE_ETHOS_HOOK_RUN_DIR"), + "event.json", + ) + payload := fmt.Sprintf(`{ + "schema_version": 1, + "trace_id": "hook-trace-pre-commit", + "recorded_at_utc": "2026-05-01T12:34:56Z", + "provider": "codex", + "event": "PreToolUse", + "tool": "Bash", + "status": %q +}`, testCase.name) + + // #nosec G703 -- the hook runner creates this path under t.TempDir. + if writeErr := os.WriteFile(tracePath, []byte(payload), 0o600); writeErr != nil { + t.Fatalf("write hook trace: %v", writeErr) + } + + return testCase.status + }) + if err != nil { + t.Fatalf("RunInProcess returned error: %v", err) + } + if status != testCase.status { + t.Fatalf("status = %d, want %d", status, testCase.status) + } - store, err := codeintel.Open( - t.Context(), - filepath.Join(root, ".coding-ethos", "code-intel.duckdb"), - ) - if err != nil { - t.Fatalf("open code-intel store: %v", err) - } - defer store.Close() + store, err := codeintel.Open( + t.Context(), + filepath.Join(root, ".coding-ethos", "code-intel.duckdb"), + ) + if err != nil { + t.Fatalf("open code-intel store: %v", err) + } + defer store.Close() - file, found, err := store.GetCodeFile(t.Context(), "app.js") - if err != nil { - t.Fatalf("query indexed file: %v", err) - } + stats, err := store.Stats(t.Context()) + if err != nil { + t.Fatalf("query code-intel stats: %v", err) + } + if stats.Traces != 1 || stats.HookEvents != 1 { + t.Fatalf("stats = %#v, want one hook trace", stats) + } - if !found || file.Language != "javascript" || file.SourceModTimeUTC == "" { - t.Fatalf("indexed file = %#v, found=%v", file, found) + file, found, err := store.GetCodeFile(t.Context(), "app.js") + if err != nil { + t.Fatalf("query indexed file: %v", err) + } + if found { + t.Fatalf("routine hook unexpectedly indexed source file: %#v", file) + } + }) } } diff --git a/go/internal/hookrunnercli/git_hook.go b/go/internal/hookrunnercli/git_hook.go index a2290828..3004fd8b 100644 --- a/go/internal/hookrunnercli/git_hook.go +++ b/go/internal/hookrunnercli/git_hook.go @@ -5,6 +5,8 @@ package hookrunnercli import ( "bufio" + "bytes" + "context" "encoding/json" "fmt" "io" @@ -15,6 +17,7 @@ import ( "sync" "time" + "blackcat.ca/coding-ethos/go/internal/codeintel" "blackcat.ca/coding-ethos/go/internal/evaluators" ) @@ -116,13 +119,29 @@ func runPrePushHook(cfg Config, input io.Reader, remoteName string) int { defer restoreRoot() - files, err := pushedFiles(input, remoteName) + var copiedInput bytes.Buffer + + files, err := pushedFiles(io.TeeReader(input, &copiedInput), remoteName) if err != nil { writef(os.Stderr, "FATAL: %v\n", err) return 1 } + codeIntelFiles, codeIntelErr := pushedCodeIntelFiles( + bytes.NewReader(copiedInput.Bytes()), + remoteName, + ) + if codeIntelErr != nil { + writef( + os.Stderr, + "warning: pushed code-intel paths not resolved: %v\n", + codeIntelErr, + ) + } else { + defer refreshPrePushCodeIntel(localRepoRoot(), codeIntelFiles) + } + exit := runNamedHookGroups(cfg, []string{ "python-policy", "python-quality", @@ -510,6 +529,24 @@ func hookFilesForPreCommit(allFiles bool) ([]string, error) { } func pushedFiles(input io.Reader, remoteName string) ([]string, error) { + return pushedFilesWithDiffOptions(input, remoteName, nil, false) +} + +func pushedCodeIntelFiles(input io.Reader, remoteName string) ([]string, error) { + return pushedFilesWithDiffOptions( + input, + remoteName, + []string{"--no-renames", "--diff-filter=ACDMR"}, + true, + ) +} + +func pushedFilesWithDiffOptions( + input io.Reader, + remoteName string, + diffOptions []string, + nulTerminated bool, +) ([]string, error) { scanner := bufio.NewScanner(input) seen := map[string]bool{} files := []string{} @@ -527,18 +564,18 @@ func pushedFiles(input io.Reader, remoteName string) ([]string, error) { if fields[3] == allZeroSHA { base := newBranchDiffBase(remoteName, fields[1]) - changed, err = gitLinesInRoot( + changed, err = gitDiffPathNamesInRoot( localRepoRoot(), - "diff", - "--name-only", + diffOptions, + nulTerminated, base, fields[1], ) } else { - changed, err = gitLinesInRoot( + changed, err = gitDiffPathNamesInRoot( localRepoRoot(), - "diff", - "--name-only", + diffOptions, + nulTerminated, fields[3]+".."+fields[1], ) } @@ -564,6 +601,13 @@ func pushedFiles(input io.Reader, remoteName string) ([]string, error) { return files, nil } + return pushedFilesFromLocalHistory(diffOptions, nulTerminated) +} + +func pushedFilesFromLocalHistory( + diffOptions []string, + nulTerminated bool, +) ([]string, error) { upstream := gitOutputInRoot( localRepoRoot(), "rev-parse", @@ -572,10 +616,10 @@ func pushedFiles(input io.Reader, remoteName string) ([]string, error) { "@{upstream}", ) if upstream != "" { - changed, err := gitLinesInRoot( + changed, err := gitDiffPathNamesInRoot( localRepoRoot(), - "diff", - "--name-only", + diffOptions, + nulTerminated, upstream+"...HEAD", ) if err == nil { @@ -583,7 +627,73 @@ func pushedFiles(input io.Reader, remoteName string) ([]string, error) { } } - return gitLinesInRoot(localRepoRoot(), "diff", "--name-only", "HEAD") + return gitDiffPathNamesInRoot( + localRepoRoot(), + diffOptions, + nulTerminated, + "HEAD", + ) +} + +func gitDiffPathNamesInRoot( + root string, + diffOptions []string, + nulTerminated bool, + revisions ...string, +) ([]string, error) { + args := []string{"diff", "--name-only"} + if nulTerminated { + args = append(args, "-z") + } + + args = append(args, diffOptions...) + args = append(args, revisions...) + + if !nulTerminated { + return gitLinesInRoot(root, args...) + } + + output, err := combinedGitOutputInRoot(root, args...) + if err != nil { + return nil, fmt.Errorf( + "git %s: %w: %s", + strings.Join(args, " "), + err, + strings.TrimSpace(string(output)), + ) + } + + files := []string{} + seen := map[string]bool{} + + for rawPath := range bytes.SplitSeq(output, []byte{0}) { + if len(rawPath) == 0 { + continue + } + + path := string(rawPath) + if !seen[path] { + seen[path] = true + files = append(files, path) + } + } + + return files, nil +} + +func refreshPrePushCodeIntel(root string, files []string) { + if len(files) == 0 { + return + } + + _, err := codeintel.RefreshLintFiles(context.Background(), root, files) + if err != nil { + writef( + os.Stderr, + "warning: pushed files not incrementally indexed: %v\n", + err, + ) + } } func newBranchDiffBase(remoteName, localSHA string) string { diff --git a/go/internal/hookrunnercli/git_hook_internal_test.go b/go/internal/hookrunnercli/git_hook_internal_test.go index c0dc8ba0..7a4b5566 100644 --- a/go/internal/hookrunnercli/git_hook_internal_test.go +++ b/go/internal/hookrunnercli/git_hook_internal_test.go @@ -103,6 +103,38 @@ func TestPushedFilesParsesPrePushRefs(t *testing.T) { } } +func TestPushedCodeIntelFilesIncludesDeletionRenameSidesAndNewlines(t *testing.T) { + root := setupGitHookTestRepo(t) + chdirForTest(t, root) + mustWriteTestFile(t, "old.py", "OLD = 1\n") + mustWriteTestFile(t, "removed.py", "REMOVED = 1\n") + mustWriteTestFile(t, "line\nbreak.py", "ODD = 1\n") + runGitTestCommand(t, "add", "old.py", "removed.py", "line\nbreak.py") + runGitTestCommand(t, "commit", "-m", "test: base") + remoteSHA := gitTestOutput(t, "rev-parse", "HEAD") + + runGitTestCommand(t, "mv", "old.py", "new.py") + runGitTestCommand(t, "rm", "removed.py") + mustWriteTestFile(t, "line\nbreak.py", "ODD = 2\n") + runGitTestCommand(t, "add", "line\nbreak.py") + runGitTestCommand(t, "commit", "-m", "test: rename and delete") + localSHA := gitTestOutput(t, "rev-parse", "HEAD") + + input := strings.NewReader( + "refs/heads/main " + localSHA + " refs/heads/main " + remoteSHA + "\n", + ) + files, err := pushedCodeIntelFiles(input, "") + if err != nil { + t.Fatalf("pushedCodeIntelFiles() returned error: %v", err) + } + + for _, want := range []string{"line\nbreak.py", "new.py", "old.py", "removed.py"} { + if !slices.Contains(files, want) { + t.Fatalf("pushed code-intel files = %#v, missing %q", files, want) + } + } +} + func TestPushedFilesHandlesNewBranchAndDeleteRefs(t *testing.T) { root := setupGitHookTestRepo(t) chdirForTest(t, root) diff --git a/go/internal/hookrunnercli/toolchain_groups.go b/go/internal/hookrunnercli/toolchain_groups.go index 430c7763..2252eb8c 100644 --- a/go/internal/hookrunnercli/toolchain_groups.go +++ b/go/internal/hookrunnercli/toolchain_groups.go @@ -800,6 +800,7 @@ func runManagedPolicyTool(name string, args []string) int { InvocationCwd: repoRoot(), OutputFormat: selectedHookOutputFormat(), Args: args, + CodeIntel: true, }) } diff --git a/go/internal/lintcli/main.go b/go/internal/lintcli/main.go index d2983f28..8c6072a8 100644 --- a/go/internal/lintcli/main.go +++ b/go/internal/lintcli/main.go @@ -600,8 +600,6 @@ func refreshLintCodeIntel(root, tracePath, scope string, files []string) { err := codeintel.IngestLintTraceFile(ctx, root, tracePath) if err != nil { writeLintCLIText("warning: lint trace not ingested into code-intel: " + err.Error()) - - return } paths := lintCodeIntelPaths(scope, files) @@ -617,7 +615,10 @@ func refreshLintCodeIntel(root, tracePath, scope string, files []string) { func lintCodeIntelPaths(scope string, files []string) []string { if scope == lint.ScopeFull { - return []string{"."} + // Full lint is an ordinary diagnostic path, not authorization for a + // whole-repository code-intel walk. Operators can request that work with + // the explicit code-intel maintenance commands. + return nil } if scope != lint.ScopeFiles && diff --git a/go/internal/lintcli/main_internal_test.go b/go/internal/lintcli/main_internal_test.go index dd19c093..f98d07e3 100644 --- a/go/internal/lintcli/main_internal_test.go +++ b/go/internal/lintcli/main_internal_test.go @@ -392,6 +392,66 @@ func TestRunCLICodeIntelIngestsTraceAndIndexesFiles(t *testing.T) { } } +func TestLintCodeIntelPathsNeverExpandsFullScope(t *testing.T) { + t.Parallel() + + if paths := lintCodeIntelPaths( + lint.ScopeFull, + []string{"pkg/app.py"}, + ); len( + paths, + ) != 0 { + t.Fatalf("full-scope code-intel paths = %#v, want none", paths) + } + + paths := lintCodeIntelPaths(lint.ScopeFiles, []string{"pkg/app.py"}) + if len(paths) != 1 || paths[0] != "pkg/app.py" { + t.Fatalf("explicit code-intel paths = %#v", paths) + } +} + +func TestRefreshLintCodeIntelIndexesFilesWhenTraceIngestFails(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + target := filepath.Join(repo, samplePythonFile) + + err := os.MkdirAll(filepath.Dir(target), lintTestExecutableMode) + if err != nil { + t.Fatalf("create package dir: %v", err) + } + + err = os.WriteFile(target, []byte("VALUE = 1\n"), lintTestWriteMode) + if err != nil { + t.Fatalf("write python file: %v", err) + } + + refreshLintCodeIntel( + repo, + filepath.Join(repo, "missing-trace.json"), + lint.ScopeFiles, + []string{samplePythonFile}, + ) + + store, err := codeintel.OpenReadOnly( + context.Background(), + codeintel.DefaultDBPath(repo), + ) + if err != nil { + t.Fatalf("open code-intel store: %v", err) + } + defer store.Close() + + stats, err := store.Stats(context.Background()) + if err != nil { + t.Fatalf("code-intel stats: %v", err) + } + + if stats.Traces != 0 || stats.Files != 1 { + t.Fatalf("code-intel stats = %#v, want zero traces and one file", stats) + } +} + func TestRunCLIExplainMode(t *testing.T) { t.Parallel() diff --git a/go/internal/managedcapture/capture.go b/go/internal/managedcapture/capture.go index 5db596f6..2e03459d 100644 --- a/go/internal/managedcapture/capture.go +++ b/go/internal/managedcapture/capture.go @@ -1401,6 +1401,10 @@ func captureFormatterChanges( content, err := os.ReadFile(file) if err != nil { + if os.IsNotExist(err) { + changed = append(changed, formatterDiagnosticPath(request, file)) + } + continue } diff --git a/go/internal/managedcapture/capture_log.go b/go/internal/managedcapture/capture_log.go index 10046458..730b665d 100644 --- a/go/internal/managedcapture/capture_log.go +++ b/go/internal/managedcapture/capture_log.go @@ -59,8 +59,6 @@ func refreshCapturedToolCodeIntel(root, tracePath string, changedFiles []string) emitManagedCaptureText( "warning: captured lint trace not ingested into code-intel: " + err.Error(), ) - - return } if len(changedFiles) == 0 { diff --git a/go/internal/managedcapture/capture_test.go b/go/internal/managedcapture/capture_test.go index a4002d14..082d8b2c 100644 --- a/go/internal/managedcapture/capture_test.go +++ b/go/internal/managedcapture/capture_test.go @@ -238,6 +238,65 @@ exit 0 } } +func TestRunCapturedToolCodeIntelTombstonesFormatterDeletionOnFailure(t *testing.T) { + if runtime.GOOS == windowsGOOS { + t.Skip("shell fixture uses POSIX sh") + } + + ctx := context.Background() + repo := t.TempDir() + target := filepath.Join(repo, "pkg", "app.py") + if err := os.MkdirAll(filepath.Dir(target), executableFixtureMode); err != nil { + t.Fatalf("create package dir: %v", err) + } + if err := os.WriteFile(target, []byte("VALUE = 1\n"), 0o600); err != nil { + t.Fatalf("write python file: %v", err) + } + if _, err := codeintel.RefreshLintFiles( + ctx, + repo, + []string{"pkg/app.py"}, + ); err != nil { + t.Fatalf("prime code-intel file: %v", err) + } + + tool := filepath.Join(repo, "formatter-fixture") + writeExecutableFixture(t, tool, `#!/usr/bin/env sh +rm pkg/app.py +exit 2 +`) + + exitCode := runCapturedToolWithRequest(captureRequest{ + Tool: "ruff-format", + Parser: "ruff", + Category: toolcatalog.CategoryFormat, + ToolPath: tool, + Cwd: repo, + TraceRoot: repo, + Args: []string{"format", "pkg/app.py"}, + DiagnosticKind: toolcatalog.DiagnosticKindFormatterChangedFiles, + FileExtensions: []string{".py"}, + CodeIntel: true, + }, hookoutput.FormatTOON) + if exitCode != 2 { + t.Fatalf("exit code = %d, want 2", exitCode) + } + + store, err := codeintel.OpenReadOnly(ctx, codeintel.DefaultDBPath(repo)) + if err != nil { + t.Fatalf("open code-intel store: %v", err) + } + defer store.Close() + + file, found, err := store.GetCodeFile(ctx, "pkg/app.py") + if err != nil { + t.Fatalf("query deleted code file: %v", err) + } + if !found || file.DeletedAtUTC == "" { + t.Fatalf("deleted file = %#v, found=%v", file, found) + } +} + func TestCopyProcessOutputDrainsStdoutAndStderrConcurrently(t *testing.T) { t.Parallel() diff --git a/tests/test_makefile_contract_diagnostics.py b/tests/test_makefile_contract_diagnostics.py index 44688d2e..c495db4f 100644 --- a/tests/test_makefile_contract_diagnostics.py +++ b/tests/test_makefile_contract_diagnostics.py @@ -30,6 +30,15 @@ def test_tests_and_diagnostics_do_not_build_or_install_runtime() -> None: "check-tool-configs", "check-gemini-prompts", "check-agent-skills", + "check-provider-matrix", + "parent-check", + "cutover-verify", + "pre-commit", + "pre-commit-all", + "pre-push", + "commit-msg", + "hook-plan", + "validate", "go-test", "lint", "fix", @@ -61,6 +70,30 @@ def test_tests_and_diagnostics_do_not_build_or_install_runtime() -> None: ) +def test_normal_runtime_commands_require_prebuilt_artifacts() -> None: + makefile, _lines = _makefile_lines() + + for target in ( + "parent-install", + "check-provider-matrix", + "parent-check", + "parent-lint", + "cutover-verify", + "pre-commit", + "pre-commit-all", + "pre-push", + "commit-msg", + "hook-plan", + "validate", + ): + target_line = next( + line for line in makefile.splitlines() if line.startswith(f"{target}:") + ) + assert "ensure-hook-runtime" in target_line + assert "build" not in target_line + assert "go-tools-install" not in target_line + + def test_check_blocks_unmanaged_go_module_root_binaries() -> None: makefile, _lines = _makefile_lines() From 5a803de5b8a463ef74424475166ee62d7031c458 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 18:02:51 -0600 Subject: [PATCH 17/30] fix(managedcapture): separate Go cache preparation --- .../managedcapture/sandbox_cache_env.go | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/go/internal/managedcapture/sandbox_cache_env.go b/go/internal/managedcapture/sandbox_cache_env.go index 1acef6c7..92092a07 100644 --- a/go/internal/managedcapture/sandbox_cache_env.go +++ b/go/internal/managedcapture/sandbox_cache_env.go @@ -41,6 +41,13 @@ type sandboxCacheEnvironment struct { CleanupTemp bool } +type sandboxGoCachePaths struct { + Cache string + Path string + ModCache string + GolangCILint string +} + // rustHomes reports where Cargo and Rustup live, preferring the environment so // an installation moved off its default path is still found. func rustHomes() (string, string) { @@ -183,29 +190,7 @@ func sandboxCacheEnv( return sandboxCacheEnvironment{}, err } - goCache := filepath.Join(root, sandbox.SandboxGoCachePath) - - err = makeSandboxDir(goCache, "Go cache") - if err != nil { - return sandboxCacheEnvironment{}, err - } - - goPath := filepath.Join(root, sandbox.SandboxGoPath) - goModCache := filepath.Join(root, sandbox.SandboxGoModCachePath) - - err = makeSandboxDir(goPath, "Go path") - if err != nil { - return sandboxCacheEnvironment{}, err - } - - err = makeSandboxDir(goModCache, "Go module cache") - if err != nil { - return sandboxCacheEnvironment{}, err - } - - golangCILintDir := filepath.Join(root, sandbox.SandboxGolangCIPath) - - err = makeSandboxDir(golangCILintDir, "golangci-lint cache") + goCaches, err := prepareSandboxGoCachePaths(root) if err != nil { return sandboxCacheEnvironment{}, err } @@ -219,10 +204,10 @@ func sandboxCacheEnv( TempDir: tempDir, RuntimeDir: runtimeDir, CleanupTemp: cleanupTemp, - GoCache: goCache, - GoPath: goPath, - GoModCache: goModCache, - GolangCILintDir: golangCILintDir, + GoCache: goCaches.Cache, + GoPath: goCaches.Path, + GoModCache: goCaches.ModCache, + GolangCILintDir: goCaches.GolangCILint, GoRoot: managedGoRoot(ctx), CGOEnabled: "1", CC: cCompiler, @@ -235,6 +220,32 @@ func sandboxCacheEnv( }, nil } +func prepareSandboxGoCachePaths(root string) (sandboxGoCachePaths, error) { + paths := sandboxGoCachePaths{ + Cache: filepath.Join(root, sandbox.SandboxGoCachePath), + Path: filepath.Join(root, sandbox.SandboxGoPath), + ModCache: filepath.Join(root, sandbox.SandboxGoModCachePath), + GolangCILint: filepath.Join(root, sandbox.SandboxGolangCIPath), + } + + for _, dir := range []struct { + path string + what string + }{ + {paths.Cache, "Go cache"}, + {paths.Path, "Go path"}, + {paths.ModCache, "Go module cache"}, + {paths.GolangCILint, "golangci-lint cache"}, + } { + err := makeSandboxDir(dir.path, dir.what) + if err != nil { + return sandboxGoCachePaths{}, err + } + } + + return paths, nil +} + func resolvedManagedSubprocessGit(ctx context.Context) (string, error) { envGit := strings.TrimSpace(os.Getenv(evaluators.RealGitEnv)) if envGit != "" { From fb811f272c3c08799e79d0a3ee2433a3e15a284f Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 18:35:50 -0600 Subject: [PATCH 18/30] fix(hooks): enforce critical timeout ceiling --- config.yaml | 2 +- .../hookrunnercli/git_hook_internal_test.go | 28 +++++++++++++++++++ go/internal/hookrunnercli/hook_report.go | 10 +++++++ go/internal/hookrunnercli/lint_constants.go | 2 +- go/internal/syncstate/state.go | 13 +++++---- repo_config.example.yaml | 2 +- 6 files changed, 49 insertions(+), 8 deletions(-) diff --git a/config.yaml b/config.yaml index e44e876c..b177c0ee 100644 --- a/config.yaml +++ b/config.yaml @@ -70,7 +70,7 @@ hooks: output_format: auto success_output: silent parallel_groups: true - tool_timeout_seconds: 900 + tool_timeout_seconds: 600 fail_severity_levels: - error - fatal diff --git a/go/internal/hookrunnercli/git_hook_internal_test.go b/go/internal/hookrunnercli/git_hook_internal_test.go index 7a4b5566..5b35e483 100644 --- a/go/internal/hookrunnercli/git_hook_internal_test.go +++ b/go/internal/hookrunnercli/git_hook_internal_test.go @@ -396,6 +396,34 @@ hooks: } } +func TestGitHooksRejectTimeoutsBeyondCriticalCeiling(t *testing.T) { + tempDir := setupGitHookTestRepo(t) + t.Chdir(tempDir) + t.Setenv("CODING_ETHOS_REAL_GIT", "") + t.Setenv(consumerRootEnv, tempDir) + bundleRoot := writeTestBundleRoot(t, tempDir) + t.Setenv(precommitRootEnv, bundleRoot) + + overridePath := filepath.Join(tempDir, "repo_config.yaml") + mustWriteTestFile( + t, + overridePath, + "hooks:\n tool_timeout_seconds: 601\n", + ) + t.Setenv(configEnv, overridePath) + + stderr := captureStderr(t, func() { + if got := runPreCommitHook(Config{}, nil); got != 1 { + t.Fatalf("runPreCommitHook() = %d, want 1", got) + } + }) + for _, want := range []string{"must not exceed 600", "critical failure"} { + if !strings.Contains(stderr, want) { + t.Fatalf("stderr missing %q:\n%s", want, stderr) + } + } +} + func TestGitHooksRejectRemovedEnabledGroupsConfigSpellings(t *testing.T) { for _, key := range []string{"enabled-groups", "enabledGroups", "ENABLED_GROUPS"} { t.Run(key, func(t *testing.T) { diff --git a/go/internal/hookrunnercli/hook_report.go b/go/internal/hookrunnercli/hook_report.go index 464b6031..8672b60e 100644 --- a/go/internal/hookrunnercli/hook_report.go +++ b/go/internal/hookrunnercli/hook_report.go @@ -261,6 +261,16 @@ func loadHookSettings() hookSettings { settings.ToolTimeoutSeconds = defaultHookSettings().ToolTimeoutSeconds } + if settings.ToolTimeoutSeconds > defaultToolTimeoutSecs { + settings.ConfigError = fmt.Sprintf( + "hooks.tool_timeout_seconds must not exceed %d; "+ + "a hook over ten minutes is a critical failure", + defaultToolTimeoutSecs, + ) + + return settings + } + if len(settings.FailSeverityLevels) == 0 { settings.FailSeverityLevels = defaultHookSettings().FailSeverityLevels } diff --git a/go/internal/hookrunnercli/lint_constants.go b/go/internal/hookrunnercli/lint_constants.go index a044362c..dfbead5b 100644 --- a/go/internal/hookrunnercli/lint_constants.go +++ b/go/internal/hookrunnercli/lint_constants.go @@ -6,7 +6,7 @@ package hookrunnercli const ( compactDividerWidth = 60 defaultPythonVersion = "3.13" - defaultToolTimeoutSecs = 900 + defaultToolTimeoutSecs = 600 executePermissionMask = 0o111 extBash = ".bash" extPy = ".py" diff --git a/go/internal/syncstate/state.go b/go/internal/syncstate/state.go index 52b46d2f..5806fc5a 100644 --- a/go/internal/syncstate/state.go +++ b/go/internal/syncstate/state.go @@ -31,6 +31,9 @@ const ( artifactStatusPlanned = "planned" artifactStatusUnchanged = "unchanged" artifactStatusWouldUpdate = "would_update" + artifactPlanNone = "none" + artifactPlanRepair = "repair" + artifactPlanWrite = "write" sourceStatusCurrent = "current" sourceStatusMissing = "missing" sourceStatusStale = "stale" @@ -399,19 +402,19 @@ func artifactReports( switch { case err != nil: report.Status = artifactStatusMissing - report.Plan = "write" + report.Plan = artifactPlanWrite case actual == artifact.ExpectedSHA256: report.ActualSHA256 = actual report.Status = artifactStatusUnchanged - report.Plan = "none" + report.Plan = artifactPlanNone case dryRun: report.ActualSHA256 = actual report.Status = artifactStatusWouldUpdate - report.Plan = "write" + report.Plan = artifactPlanWrite default: report.ActualSHA256 = actual report.Status = artifactStatusDrifted - report.Plan = "repair" + report.Plan = artifactPlanRepair } reports = append(reports, report) @@ -474,7 +477,7 @@ func plannedWriteCount(artifacts []ArtifactReport) int { count := 0 for _, artifact := range artifacts { - if artifact.Plan == "write" || artifact.Plan == "repair" { + if artifact.Plan == artifactPlanWrite || artifact.Plan == artifactPlanRepair { count++ } } diff --git a/repo_config.example.yaml b/repo_config.example.yaml index a3bf0436..21553404 100644 --- a/repo_config.example.yaml +++ b/repo_config.example.yaml @@ -57,7 +57,7 @@ hooks: output_format: auto success_output: silent parallel_groups: true - tool_timeout_seconds: 900 + tool_timeout_seconds: 600 similarity: enabled: true From f473cc78e26c2cf8b5a2b3c96f278965895ea3db Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 19:00:39 -0600 Subject: [PATCH 19/30] fix(lint): make coverage checks repository-aware Capture hook Git arguments without relying on Git tracing, and skip repository helper inspection when a lint scope contains no shell sources. --- .../evaluators/shell_best_practices.go | 17 ++++++++++---- .../evaluators/shell_best_practices_test.go | 23 +++++++++++++++++++ go/internal/hooklog/runner_test.go | 18 +++++++-------- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/go/internal/evaluators/shell_best_practices.go b/go/internal/evaluators/shell_best_practices.go index 9a53381a..2964bac0 100644 --- a/go/internal/evaluators/shell_best_practices.go +++ b/go/internal/evaluators/shell_best_practices.go @@ -46,6 +46,17 @@ func EvaluateShellBestPractices( return nil, nil } + shellFiles := make([]string, 0, len(context.Files)) + for _, file := range context.Files { + if looksLikeShellFile(file) { + shellFiles = append(shellFiles, file) + } + } + + if len(shellFiles) == 0 { + return nil, nil + } + requireCommon := stringSliceOption( context.EvaluatorOptions, "require_common_for_prefixes", @@ -64,11 +75,7 @@ func EvaluateShellBestPractices( requireCommon = nil } - for _, file := range context.Files { - if !looksLikeShellFile(file) { - continue - } - + for _, file := range shellFiles { text, binary, err := readShellText(file) if err != nil { return nil, err diff --git a/go/internal/evaluators/shell_best_practices_test.go b/go/internal/evaluators/shell_best_practices_test.go index 10368c21..5460a15d 100644 --- a/go/internal/evaluators/shell_best_practices_test.go +++ b/go/internal/evaluators/shell_best_practices_test.go @@ -113,6 +113,29 @@ func TestEvaluateShellBestPracticesRejectsMissingRepositoryContext(t *testing.T) } } +func TestEvaluateShellBestPracticesSkipsRepositoryInspectionForNonShellFiles( + t *testing.T, +) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + if err := os.WriteFile(path, []byte("package main\n"), 0o600); err != nil { + t.Fatalf("write non-shell source: %v", err) + } + + decisions, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{Cwd: dir, Files: []string{path}}, + ) + if err != nil { + t.Fatalf("non-shell source triggered repository inspection: %v", err) + } + if len(decisions) != 0 { + t.Fatalf("non-shell source decisions = %#v", decisions) + } +} + func TestEvaluateShellBestPracticesPropagatesGitInspectionFailure(t *testing.T) { t.Parallel() diff --git a/go/internal/hooklog/runner_test.go b/go/internal/hooklog/runner_test.go index 0551582c..0de01e02 100644 --- a/go/internal/hooklog/runner_test.go +++ b/go/internal/hooklog/runner_test.go @@ -5,7 +5,6 @@ package hooklog_test import ( "bytes" - "context" "errors" "fmt" "os" @@ -16,7 +15,6 @@ import ( "blackcat.ca/coding-ethos/go/internal/codeintel" . "blackcat.ca/coding-ethos/go/internal/hooklog" - "blackcat.ca/coding-ethos/go/internal/realgit" "blackcat.ca/coding-ethos/go/internal/testlock" ) @@ -182,14 +180,10 @@ func TestRunChecksIgnoresWithoutIndex(t *testing.T) { root := t.TempDir() writeHookLogIgnore(t, root) logPath := filepath.Join(t.TempDir(), "git-args.log") - t.Setenv("GIT_TRACE", logPath) - - git, err := realgit.Resolve(context.Background(), "git") - if err != nil { - t.Fatalf("resolve real git: %v", err) - } + t.Setenv("CODE_ETHOS_HOOKLOG_TEST_GIT_ARGS", logPath) + git := fakeGit(t) - err = Run(Options{ + err := Run(Options{ Stdin: strings.NewReader(""), Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, @@ -205,7 +199,7 @@ func TestRunChecksIgnoresWithoutIndex(t *testing.T) { t.Fatalf("run hook log: %v", err) } - assertFileContains(t, logPath, "check-ignore --no-index --quiet") + assertFileContains(t, logPath, "check-ignore\n--no-index\n--quiet\n") } func TestRunIngestsHookTraceIntoCodeIntel(t *testing.T) { @@ -601,6 +595,10 @@ func fakeFailingGit(t *testing.T) string { func fakeGitCheckIgnoreScript() string { return `#!/usr/bin/env bash +if [ -n "${CODE_ETHOS_HOOKLOG_TEST_GIT_ARGS:-}" ]; then + printf '%s\n' "$@" > "$CODE_ETHOS_HOOKLOG_TEST_GIT_ARGS" +fi + root="" target="" while [ "$#" -gt 0 ]; do From 0161a7656ecc2e98984b42028b951b223e398cc1 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 19:13:45 -0600 Subject: [PATCH 20/30] fix(sandbox): admit private lane state writes --- go/cmd/coding-ethos-run/main_test.go | 63 ++++++++++++++ go/cmd/coding-ethos-run/runtime_exec.go | 110 ++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 827af2e1..55765795 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -1844,6 +1844,8 @@ func TestAgentShellSandboxPlanRoutesThroughNativeWrapper(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("agent-shell native sandbox is Linux-only") } + t.Setenv("CODING_ETHOS_AGENT_SHELL_SANDBOX", "") + t.Setenv("CODING_ETHOS_SANDBOX_ACTIVE", "") home := t.TempDir() runtimeDir := t.TempDir() @@ -1901,6 +1903,7 @@ func TestAgentShellSandboxPlanRoutesThroughNativeWrapper(t *testing.T) { t.Setenv(envAgentAPIProxyURL, "http://127.0.0.1:18080") paths := runtimeTestPaths(t) + paths.StateRoot = t.TempDir() paths.GitDir = filepath.Join( filepath.Dir(paths.GitCommonDir), "worktrees", @@ -1985,6 +1988,7 @@ func TestAgentShellSandboxPlanRoutesThroughNativeWrapper(t *testing.T) { for _, want := range []string{ paths.GitDir, paths.GitCommonDir, + filepath.Join(paths.StateRoot, ".coding-ethos"), filepath.Join(home, ".gnupg"), resolvedGPGHome, resolvedPrivateKeys, @@ -2010,6 +2014,65 @@ func TestAgentShellSandboxPlanRoutesThroughNativeWrapper(t *testing.T) { } } +func TestAgentShellWritePathsAdmitsOnlyDistinctPrivateStateArtifacts(t *testing.T) { + t.Parallel() + + root := t.TempDir() + stateRoot := t.TempDir() + + got, err := agentShellWritePaths(root, stateRoot) + if err != nil { + t.Fatalf("agentShellWritePaths() error = %v", err) + } + + want := filepath.Join(stateRoot, ".coding-ethos") + if !slices.Contains(got, want) { + t.Fatalf("write paths missing private state artifacts %q: %#v", want, got) + } + info, err := os.Lstat(want) + if err != nil { + t.Fatalf("inspect private state artifacts: %v", err) + } + if !info.IsDir() || info.Mode().Perm() != 0o700 { + t.Fatalf("private state artifacts mode = %v, want directory 0700", info.Mode()) + } + if slices.Contains(got, stateRoot) { + t.Fatalf("write paths grant broad state root %q: %#v", stateRoot, got) + } +} + +func TestAgentShellWritePathsKeepsSameRootPolicyTreeProtected(t *testing.T) { + t.Parallel() + + root := t.TempDir() + got, err := agentShellWritePaths(root, root) + if err != nil { + t.Fatalf("agentShellWritePaths() error = %v", err) + } + + protected := filepath.Join(root, ".coding-ethos") + if slices.Contains(got, protected) { + t.Fatalf("write paths grant protected policy tree %q: %#v", protected, got) + } +} + +func TestAgentShellExternalStateWritePathRejectsUnsafeRoots(t *testing.T) { + t.Parallel() + + root := t.TempDir() + symlinkTarget := t.TempDir() + symlinkStateRoot := filepath.Join(t.TempDir(), "state-link") + if err := os.Symlink(symlinkTarget, symlinkStateRoot); err != nil { + t.Fatalf("create state-root symlink: %v", err) + } + + for _, stateRoot := range []string{"relative/state", string(filepath.Separator), symlinkStateRoot} { + if _, err := agentShellExternalStateWritePath(root, stateRoot); err == nil { + t.Fatalf("agentShellExternalStateWritePath(%q) succeeded", stateRoot) + } + } +} + func TestAgentShellGitWritePathsDeduplicatesGitDirs(t *testing.T) { t.Parallel() diff --git a/go/cmd/coding-ethos-run/runtime_exec.go b/go/cmd/coding-ethos-run/runtime_exec.go index 10bafdd6..d5205219 100644 --- a/go/cmd/coding-ethos-run/runtime_exec.go +++ b/go/cmd/coding-ethos-run/runtime_exec.go @@ -74,6 +74,15 @@ var ( errSharedLockDirectoryShape = apperror.StaticError( "shared lock directory must be a non-symlink mode-1777 direct child of /var/tmp", ) + errAgentShellExternalStateRootPath = apperror.StaticError( + "agent-shell external state root must be a specific absolute path", + ) + errAgentShellExternalStateRootShape = apperror.StaticError( + "agent-shell external state root must be a real directory", + ) + errAgentShellExternalStateArtifactShape = apperror.StaticError( + "agent-shell external state directory must be a real directory", + ) ) type runtimeExecutor interface { @@ -208,7 +217,10 @@ func agentShellSandboxPlan( return sandbox.Plan{}, nil, cleanup, err } - agentWritePaths, err := agentShellWritePaths(paths.Root) + agentWritePaths, err := agentShellWritePaths( + paths.Root, + paths.effectiveStateRoot(), + ) if err != nil { cleanup() @@ -267,11 +279,12 @@ func agentShellSandboxPlan( } // agentShellWritePaths assembles the full agent-shell writable set: the -// worktree paths, the managed write directories, and the tool-config drift -// manifest. The manifest lives directly under the read-only .coding-ethos pin -// and is rewritten by config generation; without its override, -// sync-tool-configs fails with EROFS inside the agent shell. -func agentShellWritePaths(root string) ([]string, error) { +// worktree paths, the managed write directories, an optional supervisor-owned +// private state tree, and the tool-config drift manifest. The manifest lives +// directly under the read-only .coding-ethos pin and is rewritten by config +// generation; without its override, sync-tool-configs fails with EROFS inside +// the agent shell. +func agentShellWritePaths(root, stateRoot string) ([]string, error) { agentWritePaths, err := agentShellWorktreeWritePaths(root) if err != nil { return nil, err @@ -291,12 +304,97 @@ func agentShellWritePaths(root string) ([]string, error) { agentWritePaths = append(agentWritePaths, sharedLockDirectories...) + externalStatePath, err := agentShellExternalStateWritePath(root, stateRoot) + if err != nil { + return nil, err + } + + if externalStatePath != "" { + agentWritePaths = append(agentWritePaths, externalStatePath) + } + // Repo-relative on purpose: the sandbox helper only creates missing // write-path files for relative declarations, and repos that have never // generated configs do not have the manifest yet. return append(agentWritePaths, toolconfigs.HashManifestPath), nil } +// agentShellExternalStateWritePath admits only the generated .coding-ethos +// subtree beneath a distinct supervisor-owned state root. The consumer +// worktree's tracked .coding-ethos tree remains pinned read-only, while hook +// receipts, event logs, and code-intel state can be written outside it. +func agentShellExternalStateWritePath(root, stateRoot string) (string, error) { + cleanStateRoot := filepath.Clean(strings.TrimSpace(stateRoot)) + if cleanStateRoot == "." || sameCleanPath(root, cleanStateRoot) { + return "", nil + } + + err := validateAgentShellExternalStateRoot(cleanStateRoot) + if err != nil { + return "", err + } + + return ensureAgentShellExternalStateArtifacts(cleanStateRoot) +} + +func validateAgentShellExternalStateRoot(stateRoot string) error { + if !filepath.IsAbs(stateRoot) || stateRoot == string(filepath.Separator) { + return fmt.Errorf("%w: %s", errAgentShellExternalStateRootPath, stateRoot) + } + + stateRootInfo, err := os.Lstat(stateRoot) + if err != nil { + return fmt.Errorf( + "inspect agent-shell external state root %s: %w", + stateRoot, + err, + ) + } + + if stateRootInfo.Mode()&os.ModeSymlink != 0 || !stateRootInfo.IsDir() { + return fmt.Errorf("%w: %s", errAgentShellExternalStateRootShape, stateRoot) + } + + return nil +} + +func ensureAgentShellExternalStateArtifacts(stateRoot string) (string, error) { + artifactRoot := filepath.Join(stateRoot, ".coding-ethos") + + err := os.Mkdir(artifactRoot, agentShellCacheDirMode) + if err != nil && !os.IsExist(err) { + return "", fmt.Errorf( + "create agent-shell external state directory %s: %w", + artifactRoot, + err, + ) + } + + artifactInfo, err := os.Lstat(artifactRoot) + if err != nil { + return "", fmt.Errorf( + "inspect agent-shell external state directory %s: %w", + artifactRoot, + err, + ) + } + + if artifactInfo.Mode()&os.ModeSymlink != 0 || !artifactInfo.IsDir() { + return "", fmt.Errorf("%w: %s", errAgentShellExternalStateArtifactShape, artifactRoot) + } + + err = os.Chmod(artifactRoot, agentShellCacheDirMode) + if err != nil { + return "", fmt.Errorf( + "make agent-shell external state directory private %s: %w", + artifactRoot, + err, + ) + } + + return artifactRoot, nil +} + // agentShellSharedLockDirectories carries only outer-supervisor-admitted host // locks into the nested Landlock policy. The outer sandbox remains the mount // authority; this inner boundary additionally requires the exact host-global From eb70e1326060be64508cfc9c99847930eef165f6 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 19:34:59 -0600 Subject: [PATCH 21/30] fix(runtime): keep discovery out of authority state --- go/cmd/coding-ethos-run/args.go | 4 ++++ go/cmd/coding-ethos-run/main_test.go | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/go/cmd/coding-ethos-run/args.go b/go/cmd/coding-ethos-run/args.go index 226e205d..0258984e 100644 --- a/go/cmd/coding-ethos-run/args.go +++ b/go/cmd/coding-ethos-run/args.go @@ -294,6 +294,10 @@ func shouldLogRuntimeCommand(args []string) bool { return false } + if slices.Contains([]string{"--help", "-h", "--version"}, args[0]) { + return false + } + if args[0] == "cutover" || args[0] == "lfs-hook" { return false } diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 55765795..780015d0 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -453,8 +453,15 @@ func TestWithCommandRootsUsesCommandSpecificRepositoryFlags(t *testing.T) { func TestCapabilitiesDiscoveryDoesNotWriteRuntimeLog(t *testing.T) { t.Parallel() - if shouldLogRuntimeCommand([]string{"agent-hooks", "capabilities"}) { - t.Fatal("capabilities discovery should be read-only") + for _, args := range [][]string{ + {"agent-hooks", "capabilities"}, + {"--help"}, + {"-h"}, + {"--version"}, + } { + if shouldLogRuntimeCommand(args) { + t.Fatalf("read-only discovery %q should not write a runtime log", args) + } } if !shouldLogRuntimeCommand([]string{"agent-hooks", "doctor"}) { t.Fatal("doctor should retain runtime logging") From b254581be8bfb64b6c88d82993b7828d5fd8bab6 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Mon, 31 Aug 2026 20:08:10 -0600 Subject: [PATCH 22/30] fix(review): close runtime and timeout gate gaps --- Makefile | 7 ++++ docs/RELEASE.md | 8 ++++ go/cmd/coding-ethos-run/main_test.go | 40 +++++++++++++++++++ go/cmd/coding-ethos-run/runtime_exec.go | 16 ++++---- .../hookrunnercli/git_hook_internal_test.go | 2 +- go/internal/hookrunnercli/hook_report.go | 10 +++-- go/internal/hookrunnercli/lint_constants.go | 1 + tests/test_makefile_contract_diagnostics.py | 6 +++ 8 files changed, 78 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 82634740..c94f76a0 100644 --- a/Makefile +++ b/Makefile @@ -364,6 +364,13 @@ ensure-hook-runtime: ## Verify managed hook runtime artifacts already exist with printf '$(COLOR_WARN)Run `make build` explicitly before tests or diagnostics.$(COLOR_RESET)\n' >&2; \ exit 2; \ } + @for tool in coding-ethos-agent-hooks coding-ethos-policy; do \ + test -x "$(GO_TOOLS_BIN_DIR)/$$tool" || { \ + printf '$(COLOR_WARN)Managed tool is missing: $(GO_TOOLS_BIN_DIR)/%s.$(COLOR_RESET)\n' "$$tool" >&2; \ + printf '$(COLOR_WARN)Run `make build` explicitly before tests or diagnostics.$(COLOR_RESET)\n' >&2; \ + exit 2; \ + }; \ + done @test -s "$(POLICY_DIR)/policy-bundle.json" || { \ printf '$(COLOR_WARN)Compiled policy bundle is missing: $(POLICY_DIR)/policy-bundle.json.$(COLOR_RESET)\n' >&2; \ printf '$(COLOR_WARN)Run `make build` explicitly before tests or diagnostics.$(COLOR_RESET)\n' >&2; \ diff --git a/docs/RELEASE.md b/docs/RELEASE.md index e5d31c4e..cb01717f 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -49,6 +49,14 @@ The normal upgrade path is: 4. Run the project gate, normally `make check`. 5. Review the release notes for any release-specific migration steps. +### Hook timeout ceiling migration + +Before upgrading a consumer that sets `hooks.tool_timeout_seconds`, reduce any +value from 601 through 900 to 600 seconds or less. Those former values now +produce a FATAL configuration error and intentionally block pre-commit and +pre-push: a hook running longer than ten minutes is a critical failure, not a +normal operating mode. + ## Pre-Release Checklist - [ ] Prepare release metadata on a release branch or use the documented diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 780015d0..5498bee2 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -2048,6 +2048,46 @@ func TestAgentShellWritePathsAdmitsOnlyDistinctPrivateStateArtifacts(t *testing. } } +func TestAgentShellExternalStateArtifactsOnlyTightenExistingPermissions(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + mode os.FileMode + want os.FileMode + }{ + {name: "already private", mode: 0o700, want: 0o700}, + {name: "stricter owner mode", mode: 0o500, want: 0o500}, + {name: "group readable", mode: 0o750, want: 0o700}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + stateRoot := t.TempDir() + artifactRoot := filepath.Join(stateRoot, ".coding-ethos") + if err := os.Mkdir(artifactRoot, testCase.mode); err != nil { + t.Fatalf("create external state artifacts: %v", err) + } + + got, err := ensureAgentShellExternalStateArtifacts(stateRoot) + if err != nil { + t.Fatalf("ensureAgentShellExternalStateArtifacts() error = %v", err) + } + if got != artifactRoot { + t.Fatalf("artifact root = %q, want %q", got, artifactRoot) + } + + info, err := os.Lstat(artifactRoot) + if err != nil { + t.Fatalf("inspect external state artifacts: %v", err) + } + if info.Mode().Perm() != testCase.want { + t.Fatalf("external state mode = %v, want %v", info.Mode().Perm(), testCase.want) + } + }) + } +} + func TestAgentShellWritePathsKeepsSameRootPolicyTreeProtected(t *testing.T) { t.Parallel() diff --git a/go/cmd/coding-ethos-run/runtime_exec.go b/go/cmd/coding-ethos-run/runtime_exec.go index d5205219..54646189 100644 --- a/go/cmd/coding-ethos-run/runtime_exec.go +++ b/go/cmd/coding-ethos-run/runtime_exec.go @@ -383,13 +383,15 @@ func ensureAgentShellExternalStateArtifacts(stateRoot string) (string, error) { return "", fmt.Errorf("%w: %s", errAgentShellExternalStateArtifactShape, artifactRoot) } - err = os.Chmod(artifactRoot, agentShellCacheDirMode) - if err != nil { - return "", fmt.Errorf( - "make agent-shell external state directory private %s: %w", - artifactRoot, - err, - ) + if artifactInfo.Mode().Perm()&^agentShellCacheDirMode != 0 { + err = os.Chmod(artifactRoot, agentShellCacheDirMode) + if err != nil { + return "", fmt.Errorf( + "make agent-shell external state directory private %s: %w", + artifactRoot, + err, + ) + } } return artifactRoot, nil diff --git a/go/internal/hookrunnercli/git_hook_internal_test.go b/go/internal/hookrunnercli/git_hook_internal_test.go index 5b35e483..75f0c34d 100644 --- a/go/internal/hookrunnercli/git_hook_internal_test.go +++ b/go/internal/hookrunnercli/git_hook_internal_test.go @@ -417,7 +417,7 @@ func TestGitHooksRejectTimeoutsBeyondCriticalCeiling(t *testing.T) { t.Fatalf("runPreCommitHook() = %d, want 1", got) } }) - for _, want := range []string{"must not exceed 600", "critical failure"} { + for _, want := range []string{"must not exceed 600 seconds", "10m0s", "critical failure"} { if !strings.Contains(stderr, want) { t.Fatalf("stderr missing %q:\n%s", want, stderr) } diff --git a/go/internal/hookrunnercli/hook_report.go b/go/internal/hookrunnercli/hook_report.go index 8672b60e..f86bdf2e 100644 --- a/go/internal/hookrunnercli/hook_report.go +++ b/go/internal/hookrunnercli/hook_report.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "time" "blackcat.ca/coding-ethos/go/diagnostics" "blackcat.ca/coding-ethos/go/internal/feedback" @@ -261,11 +262,12 @@ func loadHookSettings() hookSettings { settings.ToolTimeoutSeconds = defaultHookSettings().ToolTimeoutSeconds } - if settings.ToolTimeoutSeconds > defaultToolTimeoutSecs { + if settings.ToolTimeoutSeconds > maxToolTimeoutSecs { settings.ConfigError = fmt.Sprintf( - "hooks.tool_timeout_seconds must not exceed %d; "+ - "a hook over ten minutes is a critical failure", - defaultToolTimeoutSecs, + "hooks.tool_timeout_seconds must not exceed %d seconds; "+ + "a hook longer than %s is a critical failure", + maxToolTimeoutSecs, + time.Duration(maxToolTimeoutSecs)*time.Second, ) return settings diff --git a/go/internal/hookrunnercli/lint_constants.go b/go/internal/hookrunnercli/lint_constants.go index dfbead5b..f045181e 100644 --- a/go/internal/hookrunnercli/lint_constants.go +++ b/go/internal/hookrunnercli/lint_constants.go @@ -7,6 +7,7 @@ const ( compactDividerWidth = 60 defaultPythonVersion = "3.13" defaultToolTimeoutSecs = 600 + maxToolTimeoutSecs = 600 executePermissionMask = 0o111 extBash = ".bash" extPy = ".py" diff --git a/tests/test_makefile_contract_diagnostics.py b/tests/test_makefile_contract_diagnostics.py index c495db4f..51771f4f 100644 --- a/tests/test_makefile_contract_diagnostics.py +++ b/tests/test_makefile_contract_diagnostics.py @@ -93,6 +93,12 @@ def test_normal_runtime_commands_require_prebuilt_artifacts() -> None: assert "build" not in target_line assert "go-tools-install" not in target_line + runtime_guard = _target_block(makefile, "ensure-hook-runtime") + for tool in ("coding-ethos-agent-hooks", "coding-ethos-policy"): + assert tool in runtime_guard + assert "Managed tool is missing" in runtime_guard + assert "make build" in runtime_guard + def test_check_blocks_unmanaged_go_module_root_binaries() -> None: makefile, _lines = _makefile_lines() From bd26b8a4c6df99401fc2070bc64d9a8fa5e1ff5c Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 1 Sep 2026 02:16:27 -0600 Subject: [PATCH 23/30] fix(hooks): close runtime review gaps --- Makefile | 33 +- README.md | 25 +- docs/HOOK_RUNTIME_BOOTSTRAP.md | 50 ++- go/cmd/coding-ethos-run/dispatch.go | 40 +++ go/cmd/coding-ethos-run/main_test.go | 303 +++++++++++++++- go/cmd/coding-ethos-run/parent_workflow.go | 324 ++++++++++++++++-- go/cmd/coding-ethos-run/runtime_exec.go | 16 +- .../search_identity_migration_test.go | 101 ++++++ go/internal/codeintel/store.go | 63 +++- go/internal/e2e/git_commit_workflow_test.go | 89 ++++- go/internal/e2e/mcp_test.go | 4 +- go/internal/e2e/process_unix.go | 23 +- go/internal/e2e/process_unix_test.go | 169 +++++++++ go/internal/e2e/process_windows.go | 23 +- go/internal/e2e/scenario.go | 20 +- go/internal/evaluators/cel_expression.go | 203 +++++++++-- go/internal/evaluators/cel_expression_test.go | 76 ++++ .../evaluators/shell_best_practices.go | 12 +- .../evaluators/shell_best_practices_test.go | 53 ++- go/internal/hookrunnercli/external_tool.go | 10 +- .../external_tool_internal_test.go | 46 +++ go/internal/hookrunnercli/main.go | 1 + go/internal/hooks/gate_exit_status.go | 62 +++- go/internal/hooks/gate_exit_status_test.go | 118 +++++++ go/internal/lint/runner_test.go | 5 + go/internal/managedcapture/capture.go | 1 + go/internal/managedcapture/managed_capture.go | 11 +- .../managedcapture/sandbox_cache_env.go | 108 ++++-- .../managedcapture/sandbox_cache_env_test.go | 90 +++++ go/internal/mcp/server.go | 226 +++++++++++- go/internal/mcp/server_test.go | 280 +++++++++++++++ go/internal/policy/bundle.go | 32 +- .../policy/compiler_policies_internal_test.go | 34 +- go/internal/policygitcli/main.go | 11 + .../policygitcli/main_internal_test.go | 87 +++++ go/internal/sandbox/cgroup_linux.go | 3 + go/internal/sandbox/cgroup_linux_test.go | 28 ++ go/internal/sandbox/sandbox.go | 271 +++++++++++---- go/internal/sandbox/sandbox_internal_test.go | 45 ++- go/internal/sandbox/sandbox_test.go | 175 +++++++++- go/internal/sandboxexec/filesystem_linux.go | 82 ++++- go/internal/sandboxexec/filesystem_other.go | 15 + go/internal/sandboxexec/main.go | 10 + go/internal/sandboxexec/main_test.go | 79 +++++ pre-commit/PRE-COMMIT.md | 24 +- pre-commit/hooks/HOOKS.md | 11 +- tests/test_makefile_contract_diagnostics.py | 65 ++++ 47 files changed, 3250 insertions(+), 307 deletions(-) create mode 100644 go/internal/e2e/process_unix_test.go diff --git a/Makefile b/Makefile index c94f76a0..b2f2a8ef 100644 --- a/Makefile +++ b/Makefile @@ -109,9 +109,11 @@ GO_MODULE_ROOT_BINARY_OUTPUTS := \ GO_COVERAGE_MIN ?= 80.0 PYTHON_COVERAGE_MIN ?= 80 -GO_TEST_TIMEOUT ?= 90s +GO_TEST_TIMEOUT ?= 3m GO_COVERAGE_DIR ?= $(LOCAL_BUILD_DIR)/coverage REPO ?= $(LOCAL_REPO_ROOT) +AGENT_HOOK_STATE_ROOT ?= $(LOCAL_BUILD_DIR)/agent-hooks-state +CONSUMER_AGENT_HOOK_STATE_ROOT ?= $(LOCAL_BUILD_DIR)/consumer-agent-hooks-state PRIMARY ?= $(LOCAL_REPO_ROOT)/coding_ethos.yml REPO_ETHOS ?= REPO_CONFIG ?= @@ -561,16 +563,22 @@ _sync-consumer-agent-skills: ensure-go _sync-agent-hooks: ensure-go go-tools-install @$(call print_step,Syncing generated agent hook and MCP settings) @$(call print_info,repo: $(REPO)) - @"$(GO_TOOLS_BIN_DIR)/coding-ethos-agent-hooks" sync \ + @CODEX_HOME="$(AGENT_HOOK_STATE_ROOT)/codex-home" \ + "$(GO_TOOLS_BIN_DIR)/coding-ethos-agent-hooks" sync \ --root "$(REPO)" \ + --repo-root "$(REPO)" \ + --state-root "$(AGENT_HOOK_STATE_ROOT)" \ --hook-command "$(GO_HOOK) agent-hook" _sync-consumer-agent-hooks: ensure-go go-tools-install @if [ "$(abspath $(HOOK_CONSUMER_ROOT))" != "$(abspath $(LOCAL_REPO_ROOT))" ]; then \ $(call print_step,Syncing generated consumer agent hook and MCP settings); \ $(call print_info,repo: $(HOOK_CONSUMER_ROOT)); \ - "$(GO_TOOLS_BIN_DIR)/coding-ethos-agent-hooks" sync \ + CODEX_HOME="$(CONSUMER_AGENT_HOOK_STATE_ROOT)/codex-home" \ + "$(GO_TOOLS_BIN_DIR)/coding-ethos-agent-hooks" sync \ --root "$(HOOK_CONSUMER_ROOT)" \ + --repo-root "$(HOOK_CONSUMER_ROOT)" \ + --state-root "$(CONSUMER_AGENT_HOOK_STATE_ROOT)" \ --hook-command "$(GO_HOOK) agent-hook"; \ fi @@ -602,7 +610,10 @@ purrdf-extractor-check: ## Test the optional PurRDF code-intel extractor when pr exit 1; \ }; \ $(call print_step,Testing PurRDF code-intel extractor); \ - "$(CARGO)" test --locked --manifest-path "$(PURRDF_EXTRACTOR_MANIFEST)"; \ + "$(CARGO)" test --locked \ + --config 'build.build-dir="$(PURRDF_EXTRACTOR_DIR)/target"' \ + --target-dir "$(PURRDF_EXTRACTOR_DIR)/target" \ + --manifest-path "$(PURRDF_EXTRACTOR_MANIFEST)"; \ else \ $(call print_info,PurRDF extractor subtree not present in this checkout); \ fi @@ -614,7 +625,10 @@ purrdf-extractor-install: ## Build and install the optional PurRDF extractor whe exit 1; \ }; \ $(call print_step,Building PurRDF code-intel extractor); \ - "$(CARGO)" build --locked --release --manifest-path "$(PURRDF_EXTRACTOR_MANIFEST)"; \ + "$(CARGO)" build --locked --release \ + --config 'build.build-dir="$(PURRDF_EXTRACTOR_DIR)/target"' \ + --target-dir "$(PURRDF_EXTRACTOR_DIR)/target" \ + --manifest-path "$(PURRDF_EXTRACTOR_MANIFEST)"; \ mkdir -p "$(GO_TOOLS_BIN_DIR)"; \ install -m 0755 \ "$(PURRDF_EXTRACTOR_DIR)/target/release/$(PURRDF_EXTRACTOR_BINARY)" \ @@ -649,17 +663,15 @@ go-hook-runner-install: ensure-go ## Build the bundled Go hook runner into the c _sync-git-hooks: ensure-go go-tools-install _sync-parent-hook-runtime @$(call print_step,Syncing Git hook entrypoints) - @$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(GO_HOOK)) + @$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run) @if [ "$(HOOKS_DIR)" != "$(LOCAL_HOOKS_DIR)" ]; then \ $(call install_git_hooks,$(HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run); \ fi _sync-parent-hook-runtime: ensure-go go-tools-install policy-bundle-install @$(call print_step,Syncing parent hook runtime artifacts) - @mkdir -p "$(PARENT_HOOK_BIN_DIR)" "$(PARENT_POLICY_DIR)" - @cp "$(GO_TOOLS_BIN_DIR)"/coding-ethos-* "$(PARENT_HOOK_BIN_DIR)/" - @cp "$(GO_TOOLS_BIN_DIR)/cerun" "$(PARENT_HOOK_BIN_DIR)/cerun" - @cp "$(GO_TOOLS_BIN_DIR)/lint" "$(PARENT_HOOK_BIN_DIR)/lint" + @"$(GO_HOOK)" parent-runtime-sync --repo "$(HOOK_CONSUMER_ROOT)" + @mkdir -p "$(PARENT_POLICY_DIR)" @"$(GO_TOOLS_BIN_DIR)/coding-ethos-policy" compile \ --primary "$(LOCAL_REPO_ROOT)/coding_ethos.yml" \ --repo-ethos "$(LOCAL_REPO_ROOT)/repo_ethos.yml" \ @@ -683,7 +695,6 @@ _sync-parent-hook-runtime: ensure-go go-tools-install policy-bundle-install --tools-bin-dir "$(PARENT_HOOK_BIN_DIR)" \ --runner "$(PARENT_HOOK_BIN_DIR)/coding-ethos-run" \ --ethos-root "$(LOCAL_REPO_ROOT)" - @cp "$(GO_TOOLS_BIN_DIR)/coding-ethos-git-hook" "$(PARENT_HOOK_RUNTIME_DIR)/coding-ethos-git-hook" @$(call print_info,runtime: $(PARENT_HOOK_RUNTIME_DIR)) policy-bundle-install: ensure-go go-tools-install managed-toolchain-install ## Compile the policy bundle into the checkout-local build directory. diff --git a/README.md b/README.md index e3a9c9ed..5ca1cc17 100644 --- a/README.md +++ b/README.md @@ -968,12 +968,20 @@ parent artifacts remain normal consumer surfaces; repo-local state retains the runtime-ignore repair. Git hooks installed for a parent repository route through its stable common -Git runtime at `.git/coding-ethos-hooks/bin/coding-ethos-run`. They never point -at a worktree-local build path, so one worktree cannot strand every sibling's +Git runtime at +`/coding-ethos-hooks/bin/coding-ethos-run`. They never point at +a worktree-local build path, so one worktree cannot strand every sibling's hooks when its own checkout is retired or hidden by a lane sandbox. Running the supported parent workflow refreshes and verifies that shared executable projection as part of the same install/check contract. +The compiled `parent-runtime-sync` command exclusively installs shared Go +executables, including the root compatibility Git hook, with same-directory +temporary files and atomic rename. It preserves unrelated files, prunes +obsolete regular `coding-ethos-*` executables, and rejects managed entries that +are directories, symlinks, or special files. `parent-check` verifies exact +mode, SHA-256 content, regular-file shape, and the complete managed inventory. + Parent repos can opt into profile defaults in `repo_config.yaml`: ```yaml @@ -1462,16 +1470,17 @@ must write `merged.md`; otherwise the command fails. The bundled enforcement package lives under [pre-commit/](pre-commit/). It uses repo-local Git hook entrypoints that resolve directly to the compiled -`bin/coding-ethos-run` runner. +`/coding-ethos-hooks/bin/coding-ethos-run` runner. ### Git Hooks Installed Git hook entrypoints are small executable scripts that call -`bin/coding-ethos-run git-hook ` or -`bin/coding-ethos-run lfs-hook ` with the original Git arguments. The -runner repairs missing checkout-local runtime artifacts with `make build` and -dispatches to the built hook binary. -Policy selection and validation remain inside the `coding-ethos` checkout. +`/coding-ethos-hooks/bin/coding-ethos-run git-hook ` or +the corresponding `lfs-hook` command with the original Git arguments. Missing +or stale runtime artifacts fail closed with an explicit `make build` +instruction; hooks never build or repair runtime state implicitly. Policy +selection and validation remain inside the compiled runtime projected from the +selected `coding-ethos` authority. Run Git hooks: diff --git a/docs/HOOK_RUNTIME_BOOTSTRAP.md b/docs/HOOK_RUNTIME_BOOTSTRAP.md index 67459b86..d129c091 100644 --- a/docs/HOOK_RUNTIME_BOOTSTRAP.md +++ b/docs/HOOK_RUNTIME_BOOTSTRAP.md @@ -18,8 +18,9 @@ Its job is limited to: Policy evaluation, policy freshness, generated prompt packs, runtime command selection, managed capture, diagnostics parsing, and hook behavior belong to the compiled Coding Ethos runtime. The checked-out Coding Ethos authority is -the source and build authority; `.git/coding-ethos-hooks` is an installed, -byte-verified projection for durable sibling-worktree execution. +the source and build authority; +`/coding-ethos-hooks` is an installed, byte-verified projection +for durable sibling-worktree execution. ## Rationale @@ -56,7 +57,7 @@ are ignored by Git. The installed hook runtime lives under the consumer repository's Git common directory: ```text -coding-ethos/ +/ bin/ coding-ethos-git-hook coding-ethos-policy @@ -76,6 +77,7 @@ coding-ethos/ /coding-ethos-hooks/ bin/{coding-ethos-*,cerun,git,lint} + coding-ethos-git-hook policy/{policy-bundle.json,policy-metadata.json} pre-commit/ build/{policy,toolchain}/ @@ -110,8 +112,15 @@ dispatching to the Go hook runtime. The Go hook runtime also resolves binary tool commands to checkout-local managed paths when possible, so `shfmt`, `shellcheck`, `actionlint`, `hadolint`, `dotenv-linter`, and `golangci-lint` do not silently fall back to host binaries. Missing required managed tools or a missing installed -manifest are runtime artifact failures and should self-repair through the same -bootstrap path as missing policy binaries. +manifest are runtime artifact failures. Runtime commands fail closed and name +the explicit `make build` repair; they do not mutate the installation as a +side effect. + +Actionlint's raw JSON-on-stdin ShellCheck dependency protocol has two required +inputs: the managed protocol marker and executable identity proving that the +immediate parent is the registered managed Actionlint binary. The marker alone +is not provenance and is rejected when inherited or supplied by another +process. The managed toolchain has two installer surfaces: @@ -188,8 +197,12 @@ Examples that require repair: - missing hook binary - non-executable hook binary +- executable with a mode different from the selected authority artifact - symlinked common-runtime executable - executable whose SHA-256 differs from the authority build +- stale regular `coding-ethos-*` executable left by an older authority +- unexpected directory, symlink, or special file in the managed executable + namespace - missing compiled policy bundle - unreadable compiled policy bundle - missing managed toolchain manifest @@ -207,6 +220,33 @@ Strict freshness validation belongs in explicit maintainer/CI commands such as `make validate`, `make cutover-verify`, and CI. Freshness is based on the source hashes recorded in `build/policy/policy-metadata.json`, not mtimes. +## Executable Projection Maintenance Contract + +The compiled Go `parent-runtime-sync` command is the only installer for the +shared Go executable projection. Its inventory is every buildable command +under `go/cmd/`, plus the root-level compatibility +`coding-ethos-git-hook`. The Makefile delegates to that command and does not +copy those executables itself. + +Within the shared runtime, the `coding-ethos-*` filename prefix is reserved for +managed executables. Sync removes obsolete regular files in that namespace, +preserves unrelated filenames, and fails without removing an obsolete entry +whose shape is a directory, symlink, or special file. Expected destinations +must also be regular files or absent. + +Each executable is written to a uniquely named temporary file in its +destination directory, assigned the authority artifact's mode, flushed, and +activated with an atomic rename. Temporary files are removed on every return. +Concurrent syncs therefore converge on one complete authority artifact rather +than exposing a partial copy. `parent-check` verifies regular-file type, exact +mode, SHA-256 content, compatibility-hook presence, and absence of stale +managed entries. + +Both the authority checkout's resolved hook directory and any consumer hook +directory are shared Git state. Installed entrypoints always name +`/coding-ethos-hooks/bin/coding-ethos-run`; no hook installation +may embed the selected worktree's `bin/coding-ethos-run` path. + ## Safety Requirements Bootstrap needs a few guardrails: diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index a574b479..a4bb5240 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -1013,10 +1013,20 @@ func runAgentHooksCommand(paths runtimePaths, rest []string) { } func runPolicyTool(paths runtimePaths, rest []string) error { + parentExecutable := "" + + resolvedParent, err := os.Readlink( + filepath.Join("/proc", strconv.Itoa(os.Getppid()), "exe"), + ) + if err == nil { + parentExecutable = resolvedParent + } + return runPolicyToolForProtocol( paths, rest, os.Getenv(toolprotocol.ActionlintShellcheckEnv), + parentExecutable, ) } @@ -1024,6 +1034,7 @@ func runPolicyToolForProtocol( paths runtimePaths, rest []string, protocolMarker string, + parentExecutable string, ) error { if len(rest) == 0 { return apperror.StaticError("policy-tool requires a tool name") @@ -1036,6 +1047,12 @@ func runPolicyToolForProtocol( rest[0], rest[1:], ) { + if !managedActionlintParent(paths, parentExecutable) { + return apperror.StaticError( + "actionlint ShellCheck protocol requires the managed actionlint parent", + ) + } + tool, found := toolcatalog.HookOwnedTool("shellcheck") if !found { return apperror.StaticError("managed shellcheck tool is not registered") @@ -1053,6 +1070,29 @@ func runPolicyToolForProtocol( return nil } +func managedActionlintParent(paths runtimePaths, parentExecutable string) bool { + tool, found := toolcatalog.HookOwnedTool(toolprotocol.ActionlintTool) + if !found || strings.TrimSpace(parentExecutable) == "" { + return false + } + + expected := tool.ManagedExecutablePath(paths.EthosRoot) + + expectedInfo, err := os.Lstat(expected) + if err != nil || !expectedInfo.Mode().IsRegular() || + expectedInfo.Mode()&0o111 == 0 { + return false + } + + parentInfo, err := os.Stat(parentExecutable) + if err != nil || !parentInfo.Mode().IsRegular() || + parentInfo.Mode()&0o111 == 0 { + return false + } + + return os.SameFile(expectedInfo, parentInfo) +} + func runMCP(paths runtimePaths, rest []string) { bundlePath := hookPolicyBundlePath(paths) requireRuntimeFile(bundlePath, "compiled policy bundle") diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index 5498bee2..b9251104 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -12,13 +12,16 @@ import ( "path/filepath" "runtime" "slices" + "strconv" "strings" + "sync" "testing" "time" "blackcat.ca/coding-ethos/go/internal/apperror" "blackcat.ca/coding-ethos/go/internal/codeintel" "blackcat.ca/coding-ethos/go/internal/debuglog" + "blackcat.ca/coding-ethos/go/internal/geminiprompts" "blackcat.ca/coding-ethos/go/internal/gitwrap" "blackcat.ca/coding-ethos/go/internal/hookoutput" "blackcat.ca/coding-ethos/go/internal/policy" @@ -936,6 +939,218 @@ func TestParentHookRuntimeCheckRejectsCheckoutSymlink(t *testing.T) { } } +func TestParentHookRuntimeSyncPrunesOnlyStaleManagedExecutables(t *testing.T) { + t.Parallel() + + paths := runtimeTestPaths(t) + paths.ToolsSource = parentGoToolsSourceFixture(t, paths.EthosRoot) + touchParentGoTools(t, paths, time.Now()) + options := parentWorkflowOptions{Repo: paths.Root} + + if err := syncParentHookRuntimeExecutables(paths, options); err != nil { + t.Fatalf("initial runtime sync: %v", err) + } + + runtimeBin := parentHookRuntimeBinDir(paths, options) + stale := filepath.Join(runtimeBin, "coding-ethos-retired") + unrelated := filepath.Join(runtimeBin, "operator-helper") + writeExecutableFixture(t, stale, "retired\n") + writeExecutableFixture(t, unrelated, "preserve\n") + + err := checkParentHookRuntimeExecutables(paths, options) + if !errors.Is(err, errParentArtifactDrift) || + !strings.Contains(err.Error(), "coding-ethos-retired(stale)") { + t.Fatalf("stale runtime check error = %v", err) + } + + if err = syncParentHookRuntimeExecutables(paths, options); err != nil { + t.Fatalf("prune stale runtime: %v", err) + } + + if _, err = os.Lstat(stale); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stale managed executable still exists: %v", err) + } + if _, err = os.Lstat(unrelated); err != nil { + t.Fatalf("unrelated runtime file was not preserved: %v", err) + } +} + +func TestParentHookRuntimeSyncRejectsUnsafeManagedShape(t *testing.T) { + t.Parallel() + + paths := runtimeTestPaths(t) + paths.ToolsSource = parentGoToolsSourceFixture(t, paths.EthosRoot) + touchParentGoTools(t, paths, time.Now()) + options := parentWorkflowOptions{Repo: paths.Root} + + if err := syncParentHookRuntimeExecutables(paths, options); err != nil { + t.Fatalf("initial runtime sync: %v", err) + } + + target := filepath.Join(t.TempDir(), "outside") + writeExecutableFixture(t, target, "outside\n") + unsafe := filepath.Join( + parentHookRuntimeBinDir(paths, options), + "coding-ethos-retired", + ) + if err := os.Symlink(target, unsafe); err != nil { + t.Fatalf("create stale managed symlink: %v", err) + } + + err := syncParentHookRuntimeExecutables(paths, options) + if !errors.Is(err, errParentRuntimeUnsafeShape) { + t.Fatalf("unsafe runtime sync error = %v", err) + } + if _, err = os.Lstat(unsafe); err != nil { + t.Fatalf("unsafe managed entry was unexpectedly removed: %v", err) + } +} + +func TestParentHookRuntimeCheckVerifiesModeAndCompatibilityHook(t *testing.T) { + t.Parallel() + + paths := runtimeTestPaths(t) + paths.ToolsSource = parentGoToolsSourceFixture(t, paths.EthosRoot) + touchParentGoTools(t, paths, time.Now()) + options := parentWorkflowOptions{Repo: paths.Root} + + if err := syncParentHookRuntimeExecutables(paths, options); err != nil { + t.Fatalf("runtime sync: %v", err) + } + + compatibility := filepath.Join( + parentHookRuntimeDir(paths, options), + parentCompatibilityHook, + ) + status, err := compareParentHookRuntimeExecutable( + filepath.Join(paths.BinDir, parentCompatibilityHook), + compatibility, + ) + if err != nil || status != "" { + t.Fatalf("compatibility hook status = %q, error = %v", status, err) + } + + installed := filepath.Join( + parentHookRuntimeBinDir(paths, options), + "coding-ethos-run", + ) + if err = os.Chmod(installed, 0o755); err != nil { + t.Fatalf("change installed mode: %v", err) + } + + err = checkParentHookRuntimeExecutables(paths, options) + if !errors.Is(err, errParentArtifactDrift) || + !strings.Contains(err.Error(), "coding-ethos-run(mode)") { + t.Fatalf("mode drift error = %v", err) + } +} + +func TestParentHookRuntimeExecutableInstallIsAtomicUnderConcurrency(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + destination := filepath.Join(directory, "coding-ethos-run") + sources := make([]string, 8) + wantContent := map[string]struct{}{} + + for index := range sources { + source := filepath.Join(directory, "source-"+strconv.Itoa(index)) + content := "payload-" + strconv.Itoa(index) + "\n" + writeExecutableFixture(t, source, content) + sources[index] = source + wantContent[content] = struct{}{} + } + + var wait sync.WaitGroup + errorsCh := make(chan error, len(sources)) + for _, source := range sources { + wait.Add(1) + + go func() { + defer wait.Done() + + errorsCh <- installParentHookRuntimeExecutable(source, destination) + }() + } + + wait.Wait() + close(errorsCh) + + for err := range errorsCh { + if err != nil { + t.Fatalf("concurrent executable install: %v", err) + } + } + + payload, err := os.ReadFile(destination) + if err != nil { + t.Fatalf("read concurrent install result: %v", err) + } + if _, found := wantContent[string(payload)]; !found { + t.Fatalf("installed partial or unknown content %q", payload) + } + + temporary, err := filepath.Glob(filepath.Join(directory, ".coding-ethos-run-*.tmp")) + if err != nil { + t.Fatalf("find temporary executables: %v", err) + } + if len(temporary) != 0 { + t.Fatalf("temporary executables were not cleaned up: %#v", temporary) + } +} + +func TestParentHookRuntimeSyncUsesLinkedWorktreeCommonDirectory(t *testing.T) { + t.Parallel() + + gitPath, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find git: %v", err) + } + + primary := t.TempDir() + runRunnerTestGit(t, primary, "init", "--initial-branch", "main") + tracked := filepath.Join(primary, "tracked.txt") + if err = os.WriteFile(tracked, []byte("tracked\n"), 0o600); err != nil { + t.Fatalf("write tracked fixture: %v", err) + } + runRunnerTestGit(t, primary, "add", "tracked.txt") + runRunnerTestGit( + t, + primary, + "-c", "user.name=Test", + "-c", "user.email=test@example.invalid", + "commit", "-m", "fixture", + ) + + linked := filepath.Join(t.TempDir(), "linked") + runRunnerTestGit(t, primary, "worktree", "add", "--detach", linked) + + paths := runtimeTestPaths(t) + paths.RealGit = gitPath + paths.ToolsSource = parentGoToolsSourceFixture(t, paths.EthosRoot) + touchParentGoTools(t, paths, time.Now()) + options := parentWorkflowOptions{Repo: linked} + + if err = syncParentHookRuntimeExecutables(paths, options); err != nil { + t.Fatalf("sync linked-worktree runtime: %v", err) + } + + commonDir, err := gitOutput( + gitPath, + linked, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ) + if err != nil { + t.Fatalf("resolve linked-worktree common dir: %v", err) + } + want := filepath.Join(commonDir, "coding-ethos-hooks", "bin", "coding-ethos-run") + if _, err = os.Lstat(want); err != nil { + t.Fatalf("stable linked-worktree runtime missing at %s: %v", want, err) + } +} + func TestSyncParentPolicyBundleUsesParentRepoConfig(t *testing.T) { t.Parallel() @@ -1356,6 +1571,7 @@ func TestRuntimePathResolutionFallbacks(t *testing.T) { //nolint:paralleltest // Serializes process-global runtime environment state. func TestRuntimePathResolutionUsesGitWhenAvailable(t *testing.T) { testlock.ProcessState(t, "coding-ethos-run-env") + t.Setenv("CODE_ETHOS_CONSUMER_ROOT", "") repo := filepath.Join(t.TempDir(), "repo") hooks := filepath.Join(repo, ".git", "hooks") @@ -1374,6 +1590,7 @@ func TestRuntimePathResolutionUsesGitWhenAvailable(t *testing.T) { //nolint:paralleltest // Serializes process-global runtime environment state. func TestRuntimePathResolutionKeepsSubmoduleCheckoutLocal(t *testing.T) { testlock.ProcessState(t, "coding-ethos-run-env") + t.Setenv("CODE_ETHOS_CONSUMER_ROOT", "") parent := filepath.Join(t.TempDir(), "parent") repo := filepath.Join(parent, "coding-ethos") @@ -2097,9 +2314,22 @@ func TestAgentShellWritePathsKeepsSameRootPolicyTreeProtected(t *testing.T) { t.Fatalf("agentShellWritePaths() error = %v", err) } - protected := filepath.Join(root, ".coding-ethos") - if slices.Contains(got, protected) { - t.Fatalf("write paths grant protected policy tree %q: %#v", protected, got) + if !slices.Contains(got, geminiprompts.PromptPackPath) { + t.Fatalf( + "write paths missing exact Gemini prompt pack %q: %#v", + geminiprompts.PromptPackPath, + got, + ) + } + + for _, protected := range []string{ + filepath.Join(root, ".coding-ethos"), + ".coding-ethos", + ".coding-ethos/gemini", + } { + if slices.Contains(got, protected) { + t.Fatalf("write paths grant protected policy path %q: %#v", protected, got) + } } } @@ -2413,6 +2643,8 @@ func TestAgentShellSandboxPlanFailsClosedWithoutNativeHelper(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("agent-shell native sandbox is Linux-only") } + t.Setenv("CODING_ETHOS_AGENT_SHELL_SANDBOX", "") + t.Setenv("CODING_ETHOS_SANDBOX_ACTIVE", "") paths := runtimeTestPaths(t) @@ -2699,7 +2931,7 @@ func TestPolicyGitIgnoresArbitraryEnvRealGitExecutable(t *testing.T) { } } -func TestPolicyToolUsesMarkedActionlintShellcheckProtocolWithoutParentPath( +func TestPolicyToolUsesAuthenticatedActionlintShellcheckProtocol( t *testing.T, ) { paths := runtimeTestPaths(t) @@ -2717,6 +2949,16 @@ func TestPolicyToolUsesMarkedActionlintShellcheckProtocolWithoutParentPath( } writeExecutableFixture(t, shellcheck, "#!/usr/bin/env sh\nexit 0\n") + actionlintTool, found := toolcatalog.HookOwnedTool(toolprotocol.ActionlintTool) + if !found { + t.Fatal("managed actionlint tool is not registered") + } + actionlint := actionlintTool.ManagedExecutablePath(paths.EthosRoot) + if err := os.MkdirAll(filepath.Dir(actionlint), 0o755); err != nil { + t.Fatalf("create managed actionlint fixture directory: %v", err) + } + writeExecutableFixture(t, actionlint, "#!/usr/bin/env sh\nexit 0\n") + args := []string{ "shellcheck", "--norc", @@ -2729,6 +2971,7 @@ func TestPolicyToolUsesMarkedActionlintShellcheckProtocolWithoutParentPath( paths, args, toolprotocol.ActionlintShellcheckJSONStdinV1, + actionlint, ) if err != nil { t.Fatalf("run actionlint shellcheck dependency: %v", err) @@ -2740,6 +2983,38 @@ func TestPolicyToolUsesMarkedActionlintShellcheckProtocolWithoutParentPath( } } +func TestPolicyToolRejectsSpoofedActionlintShellcheckProtocol(t *testing.T) { + paths := runtimeTestPaths(t) + var calls []string + paths.Executor = stubRuntimeOps{calls: &calls} + + actionlintTool, found := toolcatalog.HookOwnedTool(toolprotocol.ActionlintTool) + if !found { + t.Fatal("managed actionlint tool is not registered") + } + actionlint := actionlintTool.ManagedExecutablePath(paths.EthosRoot) + if err := os.MkdirAll(filepath.Dir(actionlint), 0o755); err != nil { + t.Fatalf("create managed actionlint fixture directory: %v", err) + } + writeExecutableFixture(t, actionlint, "#!/usr/bin/env sh\nexit 0\n") + + attacker := filepath.Join(t.TempDir(), "actionlint") + writeExecutableFixture(t, attacker, "#!/usr/bin/env sh\nexit 0\n") + + err := runPolicyToolForProtocol( + paths, + []string{"shellcheck", "-f", "json", "-"}, + toolprotocol.ActionlintShellcheckJSONStdinV1, + attacker, + ) + if err == nil || !strings.Contains(err.Error(), "managed actionlint parent") { + t.Fatalf("spoofed actionlint protocol error = %v", err) + } + if len(calls) != 0 { + t.Fatalf("spoofed actionlint protocol executed a tool: %#v", calls) + } +} + func TestPolicyToolCapturesShellcheckWithoutActionlintMarker(t *testing.T) { paths := runtimeTestPaths(t) var calls []string @@ -2749,6 +3024,7 @@ func TestPolicyToolCapturesShellcheckWithoutActionlintMarker(t *testing.T) { paths, []string{"shellcheck", "-f", "json", "-"}, "", + "", ) if err != nil { t.Fatalf("run direct shellcheck: %v", err) @@ -3546,9 +3822,10 @@ func TestMakefileRoutesParentGitHooksThroughStableCommonRuntime(t *testing.T) { makefile := string(payload) for _, want := range []string{ - `$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(GO_HOOK))`, + `$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run)`, `$(call install_git_hooks,$(HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run)`, `_sync-git-hooks: ensure-go go-tools-install _sync-parent-hook-runtime`, + `"$(GO_HOOK)" parent-runtime-sync --repo "$(HOOK_CONSUMER_ROOT)"`, "--dest-dir \"$(PARENT_HOOK_BIN_DIR)\" \\\n" + "\t\t--real-git \"$(GIT)\" \\\n" + "\t\t--runner \"$(PARENT_HOOK_BIN_DIR)/coding-ethos-run\"", @@ -3560,14 +3837,25 @@ func TestMakefileRoutesParentGitHooksThroughStableCommonRuntime(t *testing.T) { if strings.Contains( makefile, - `$(call install_git_hooks,$(HOOKS_DIR),$(GO_HOOK))`, + `$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(GO_HOOK))`, ) { - t.Fatal("Makefile routes parent Git hooks through a worktree-local runner") + t.Fatal("Makefile routes shared local Git hooks through a worktree-local runner") } if strings.Contains(makefile, `--runner "$(GO_HOOK)"`) { t.Fatal("Makefile routes a parent shim through a worktree-local runner") } + + for _, forbidden := range []string{ + `cp "$(GO_TOOLS_BIN_DIR)"/coding-ethos-*`, + `cp "$(GO_TOOLS_BIN_DIR)/cerun"`, + `cp "$(GO_TOOLS_BIN_DIR)/lint"`, + `cp "$(GO_TOOLS_BIN_DIR)/coding-ethos-git-hook"`, + } { + if strings.Contains(makefile, forbidden) { + t.Fatalf("Makefile bypasses atomic runtime sync with %q", forbidden) + } + } } func fakeCIGit(t *testing.T, diffOutput string) string { @@ -4087,6 +4375,7 @@ func touchParentGoTools(t *testing.T, paths runtimePaths, modTime time.Time) { func parentGoToolFixtureCommands() []string { return []string{ + "coding-ethos-git-hook", "coding-ethos-lint", "coding-ethos-policy", "coding-ethos-run", diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index 4487e583..8b497a62 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -32,7 +32,9 @@ const ( parentCheckoutCodingEthos = "coding-ethos" parentDefaultLintScope = "full" parentExecutableDirMode = 0o755 + parentManagedToolPrefix = "coding-ethos-" parentPolicyBundleDirMode = 0o755 + parentCompatibilityHook = "coding-ethos-git-hook" parentStepFail = "fail" parentStepPass = "pass" parentWorkingTreeState = "working_tree" @@ -44,6 +46,7 @@ var ( errParentPathIsDirectory = errors.New("path is a directory, want file") errParentPathIsNotDirectory = errors.New("path is not a directory, want directory") errParentRootNotAbsolute = errors.New("parent workflow root must be absolute") + errParentRuntimeUnsafeShape = errors.New("parent hook runtime has an unsafe shape") errParentSourceNotExecutable = errors.New( "source executable is not a regular executable", ) @@ -63,6 +66,23 @@ type parentWorkflowStep struct { Detail string } +type parentRuntimeExecutable struct { + Destination string + Label string + Source string +} + +type parentRuntimeDirectory struct { + Expected map[string]struct{} + Path string +} + +type parentRuntimeEntry struct { + Name string + Path string + Regular bool +} + func runParentInstall(paths runtimePaths, rest []string) error { options, err := parseParentWorkflowFlags(paths, "parent-install", rest) if err != nil { @@ -451,10 +471,13 @@ func parentGitCommonDir(paths runtimePaths, repo string) string { } func parentHookRuntimeBinDir(paths runtimePaths, options parentWorkflowOptions) string { + return filepath.Join(parentHookRuntimeDir(paths, options), "bin") +} + +func parentHookRuntimeDir(paths runtimePaths, options parentWorkflowOptions) string { return filepath.Join( parentGitCommonDir(paths, options.Repo), "coding-ethos-hooks", - "bin", ) } @@ -462,39 +485,206 @@ func syncParentHookRuntimeExecutables( paths runtimePaths, options parentWorkflowOptions, ) error { - tools, err := parentGoToolCommands(paths) + projections, directories, err := parentHookRuntimeProjection(paths, options) + if err != nil { + return err + } + + err = ensureParentHookRuntimeDirectories(directories) if err != nil { return err } - runtimeBin := parentHookRuntimeBinDir(paths, options) + err = validateParentHookRuntimeProjections(projections) + if err != nil { + return err + } - err = os.MkdirAll(runtimeBin, parentExecutableDirMode) + err = pruneUnexpectedParentRuntimeExecutables(directories) if err != nil { - return fmt.Errorf("create parent hook runtime bin dir: %w", err) + return err + } + + return installParentHookRuntimeProjections(projections) +} + +func ensureParentHookRuntimeDirectories(directories []parentRuntimeDirectory) error { + for _, directory := range directories { + err := ensureParentHookRuntimeDirectory(directory.Path) + if err != nil { + return err + } + } + + return nil +} + +func validateParentHookRuntimeProjections(projections []parentRuntimeExecutable) error { + for _, projection := range projections { + _, err := parentRuntimeSourceInfo(projection.Source) + if err != nil { + return fmt.Errorf("validate parent hook runtime %s: %w", projection.Label, err) + } + + err = validateParentHookRuntimeDestination(projection.Destination) + if err != nil { + return fmt.Errorf("validate parent hook runtime %s: %w", projection.Label, err) + } } + return nil +} + +func pruneUnexpectedParentRuntimeExecutables( + directories []parentRuntimeDirectory, +) error { + for _, directory := range directories { + entries, err := unexpectedParentRuntimeExecutables(directory) + if err != nil { + return err + } + + for _, entry := range entries { + if !entry.Regular { + return fmt.Errorf("%w: %s", errParentRuntimeUnsafeShape, entry.Path) + } + } + + for _, entry := range entries { + err = os.Remove(entry.Path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("prune stale parent runtime %s: %w", entry.Path, err) + } + } + } + + return nil +} + +func installParentHookRuntimeProjections(projections []parentRuntimeExecutable) error { + for _, projection := range projections { + err := installParentHookRuntimeExecutable( + projection.Source, + projection.Destination, + ) + if err != nil { + return fmt.Errorf("install parent hook runtime %s: %w", projection.Label, err) + } + } + + return nil +} + +func parentHookRuntimeProjection( + paths runtimePaths, + options parentWorkflowOptions, +) ([]parentRuntimeExecutable, []parentRuntimeDirectory, error) { + tools, err := parentGoToolCommands(paths) + if err != nil { + return nil, nil, err + } + + runtimeDir := parentHookRuntimeDir(paths, options) + runtimeBin := filepath.Join(runtimeDir, "bin") + binNames := make(map[string]struct{}, len(tools)) + projections := make([]parentRuntimeExecutable, 0, len(tools)+1) + for _, tool := range tools { - source := filepath.Join(paths.BinDir, tool) - destination := filepath.Join(runtimeBin, tool) + binNames[tool] = struct{}{} + projections = append(projections, parentRuntimeExecutable{ + Destination: filepath.Join(runtimeBin, tool), + Label: tool, + Source: filepath.Join(paths.BinDir, tool), + }) + } + + projections = append(projections, parentRuntimeExecutable{ + Destination: filepath.Join(runtimeDir, parentCompatibilityHook), + Label: parentCompatibilityHook + "@compat", + Source: filepath.Join(paths.BinDir, parentCompatibilityHook), + }) - err = installParentHookRuntimeExecutable(source, destination) + return projections, []parentRuntimeDirectory{ + {Path: runtimeDir, Expected: map[string]struct{}{parentCompatibilityHook: {}}}, + {Path: runtimeBin, Expected: binNames}, + }, nil +} + +func ensureParentHookRuntimeDirectory(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + err = os.MkdirAll(path, parentExecutableDirMode) if err != nil { - return fmt.Errorf("install parent hook runtime %s: %w", tool, err) + return fmt.Errorf("create parent hook runtime directory %s: %w", path, err) } + + return nil + } + + if err != nil { + return fmt.Errorf("stat parent hook runtime directory %s: %w", path, err) + } + + if !info.IsDir() { + return fmt.Errorf("%w: %s", errParentRuntimeUnsafeShape, path) } return nil } +func unexpectedParentRuntimeExecutables( + directory parentRuntimeDirectory, +) ([]parentRuntimeEntry, error) { + entries, err := os.ReadDir(directory.Path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + if err != nil { + return nil, fmt.Errorf( + "read parent hook runtime directory %s: %w", + directory.Path, + err, + ) + } + + unexpected := []parentRuntimeEntry{} + + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), parentManagedToolPrefix) { + continue + } + + if _, found := directory.Expected[entry.Name()]; found { + continue + } + + path := filepath.Join(directory.Path, entry.Name()) + + info, statErr := os.Lstat(path) + if statErr != nil { + return nil, fmt.Errorf("stat stale parent runtime %s: %w", path, statErr) + } + + unexpected = append(unexpected, parentRuntimeEntry{ + Name: entry.Name(), + Path: path, + Regular: info.Mode().IsRegular(), + }) + } + + return unexpected, nil +} + func installParentHookRuntimeExecutable(source, destination string) error { - info, err := os.Lstat(source) + info, err := parentRuntimeSourceInfo(source) if err != nil { - return fmt.Errorf("stat source executable %s: %w", source, err) + return err } - if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { - return fmt.Errorf("%w: %s", errParentSourceNotExecutable, source) + err = validateParentHookRuntimeDestination(destination) + if err != nil { + return err } input, err := os.Open(source) @@ -546,29 +736,76 @@ func installParentHookRuntimeExecutable(source, destination string) error { return nil } +func parentRuntimeSourceInfo(source string) (fs.FileInfo, error) { + info, err := os.Lstat(source) + if err != nil { + return nil, fmt.Errorf("stat source executable %s: %w", source, err) + } + + if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 { + return nil, fmt.Errorf("%w: %s", errParentSourceNotExecutable, source) + } + + return info, nil +} + +func validateParentHookRuntimeDestination(destination string) error { + info, err := os.Lstat(destination) + if errors.Is(err, os.ErrNotExist) { + return nil + } + + if err != nil { + return fmt.Errorf("stat installed executable %s: %w", destination, err) + } + + if !info.Mode().IsRegular() { + return fmt.Errorf("%w: %s", errParentRuntimeUnsafeShape, destination) + } + + return nil +} + func checkParentHookRuntimeExecutables( paths runtimePaths, options parentWorkflowOptions, ) error { - tools, err := parentGoToolCommands(paths) + projections, directories, err := parentHookRuntimeProjection(paths, options) if err != nil { return err } - runtimeBin := parentHookRuntimeBinDir(paths, options) mismatched := []string{} - for _, tool := range tools { - source := filepath.Join(paths.BinDir, tool) - destination := filepath.Join(runtimeBin, tool) + for _, projection := range projections { + status, compareErr := compareParentHookRuntimeExecutable( + projection.Source, + projection.Destination, + ) - status, err := compareParentHookRuntimeExecutable(source, destination) + err = compareErr if err != nil { - return fmt.Errorf("check parent hook runtime %s: %w", tool, err) + return fmt.Errorf("check parent hook runtime %s: %w", projection.Label, err) } if status != "" { - mismatched = append(mismatched, tool+"("+status+")") + mismatched = append(mismatched, projection.Label+"("+status+")") + } + } + + for _, directory := range directories { + entries, listErr := unexpectedParentRuntimeExecutables(directory) + if listErr != nil { + return listErr + } + + for _, entry := range entries { + status := "stale" + if !entry.Regular { + status = "unsafe_shape" + } + + mismatched = append(mismatched, entry.Name+"("+status+")") } } @@ -586,30 +823,22 @@ func checkParentHookRuntimeExecutables( } func compareParentHookRuntimeExecutable(source, destination string) (string, error) { - sourceInfo, err := os.Lstat(source) + sourceInfo, err := parentRuntimeSourceInfo(source) if err != nil { - return "", fmt.Errorf("stat source executable %s: %w", source, err) - } - - if !sourceInfo.Mode().IsRegular() || sourceInfo.Mode()&0o111 == 0 { - return "", fmt.Errorf("%w: %s", errParentSourceNotExecutable, source) - } - - destinationInfo, err := os.Lstat(destination) - if errors.Is(err, os.ErrNotExist) { - return "missing", nil + return "", err } + destinationInfo, status, err := parentRuntimeDestinationInfo(destination) if err != nil { - return "", fmt.Errorf("stat installed executable %s: %w", destination, err) + return "", err } - if !destinationInfo.Mode().IsRegular() { - return "not_regular", nil + if status != "" { + return status, nil } - if destinationInfo.Mode()&0o111 == 0 { - return "not_executable", nil + if sourceInfo.Mode().Perm() != destinationInfo.Mode().Perm() { + return "mode", nil } if sourceInfo.Size() != destinationInfo.Size() { @@ -633,6 +862,27 @@ func compareParentHookRuntimeExecutable(source, destination string) (string, err return "", nil } +func parentRuntimeDestinationInfo(destination string) (fs.FileInfo, string, error) { + info, err := os.Lstat(destination) + if errors.Is(err, os.ErrNotExist) { + return nil, "missing", nil + } + + if err != nil { + return nil, "", fmt.Errorf("stat installed executable %s: %w", destination, err) + } + + if !info.Mode().IsRegular() { + return nil, "not_regular", nil + } + + if info.Mode()&0o111 == 0 { + return nil, "not_executable", nil + } + + return info, "", nil +} + func parentFileSHA256(path string) ([sha256.Size]byte, error) { file, err := os.Open(path) if err != nil { diff --git a/go/cmd/coding-ethos-run/runtime_exec.go b/go/cmd/coding-ethos-run/runtime_exec.go index 54646189..f796ee06 100644 --- a/go/cmd/coding-ethos-run/runtime_exec.go +++ b/go/cmd/coding-ethos-run/runtime_exec.go @@ -24,6 +24,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/codeintelcli" "blackcat.ca/coding-ethos/go/internal/debuglog" "blackcat.ca/coding-ethos/go/internal/feedback" + "blackcat.ca/coding-ethos/go/internal/geminiprompts" "blackcat.ca/coding-ethos/go/internal/githookcli" "blackcat.ca/coding-ethos/go/internal/gitwrap" "blackcat.ca/coding-ethos/go/internal/hookcli" @@ -280,10 +281,9 @@ func agentShellSandboxPlan( // agentShellWritePaths assembles the full agent-shell writable set: the // worktree paths, the managed write directories, an optional supervisor-owned -// private state tree, and the tool-config drift manifest. The manifest lives -// directly under the read-only .coding-ethos pin and is rewritten by config -// generation; without its override, sync-tool-configs fails with EROFS inside -// the agent shell. +// private state tree, and the exact generated files rewritten under the +// read-only .coding-ethos pin. Without these file overrides, canonical build +// generation fails with EROFS inside the agent shell. func agentShellWritePaths(root, stateRoot string) ([]string, error) { agentWritePaths, err := agentShellWorktreeWritePaths(root) if err != nil { @@ -315,8 +315,12 @@ func agentShellWritePaths(root, stateRoot string) ([]string, error) { // Repo-relative on purpose: the sandbox helper only creates missing // write-path files for relative declarations, and repos that have never - // generated configs do not have the manifest yet. - return append(agentWritePaths, toolconfigs.HashManifestPath), nil + // generated configs do not have these artifacts yet. + return append( + agentWritePaths, + toolconfigs.HashManifestPath, + geminiprompts.PromptPackPath, + ), nil } // agentShellExternalStateWritePath admits only the generated .coding-ethos diff --git a/go/internal/codeintel/search_identity_migration_test.go b/go/internal/codeintel/search_identity_migration_test.go index 0fd52d1a..b70efa54 100644 --- a/go/internal/codeintel/search_identity_migration_test.go +++ b/go/internal/codeintel/search_identity_migration_test.go @@ -48,6 +48,22 @@ func TestOpenMigratesExactDuplicateSearchIdentities(t *testing.T) { t.Fatalf("unexpected upgraded identity stats: %#v", stats) } + var message, searchText string + if err = store.Database().QueryRowContext( + ctx, + `SELECT message, search_text FROM code_intel_fts + WHERE fts_id = 'legacy:one'`, + ).Scan(&message, &searchText); err != nil { + t.Fatalf("read preserved v1 search identity: %v", err) + } + if message != "same" || searchText != "same text" { + t.Fatalf( + "preserved v1 search identity = (%q, %q)", + message, + searchText, + ) + } + _, err = store.Database().ExecContext( ctx, `INSERT INTO code_intel_fts(fts_id, kind, record_id, search_text) @@ -111,6 +127,82 @@ func TestOpenRejectsConflictingDuplicateSearchIdentity(t *testing.T) { } } +func TestRoutineV2SearchIdentityMigrationSkipsRecoveryScan(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, DefaultDBPath(t.TempDir())) + if err != nil { + t.Fatalf("open v2 search identity store: %v", err) + } + defer store.Close() + + dropFTSIdentityIndex(t, ctx, store.Database()) + + _, err = store.Database().ExecContext( + ctx, + `INSERT INTO code_intel_fts( + fts_id, kind, record_id, message, search_text + ) VALUES + ('v2:conflict', 'finding', 'one', 'first', 'same text'), + ('v2:conflict', 'finding', 'one', 'second', 'same text')`, + ) + if err != nil { + t.Fatalf("insert v2 recovery sentinel: %v", err) + } + + if err = migrateSearchIdentity(ctx, store.Database()); err != nil { + t.Fatalf("routine v2 migration entered recovery scan: %v", err) + } + + var rows int + if err = store.Database().QueryRowContext( + ctx, + "SELECT COUNT(*) FROM code_intel_fts WHERE fts_id = 'v2:conflict'", + ).Scan(&rows); err != nil { + t.Fatalf("count retained v2 recovery sentinel: %v", err) + } + if rows != 2 { + t.Fatalf("routine v2 migration rewrote %d sentinel rows, want 2", rows) + } +} + +func TestExplicitSearchIdentityRecoveryRepairsV2Replay(t *testing.T) { + ctx := context.Background() + store, err := Open(ctx, DefaultDBPath(t.TempDir())) + if err != nil { + t.Fatalf("open v2 recovery store: %v", err) + } + defer store.Close() + + dropFTSIdentityIndex(t, ctx, store.Database()) + + _, err = store.Database().ExecContext( + ctx, + `INSERT INTO code_intel_fts( + fts_id, kind, record_id, message, search_text + ) VALUES + ('v2:replay', 'finding', 'one', 'same', 'same text'), + ('v2:replay', 'finding', 'one', 'same', 'same text')`, + ) + if err != nil { + t.Fatalf("insert v2 replay: %v", err) + } + + if err = deduplicateSearchIdentity(ctx, store.Database()); err != nil { + t.Fatalf("run explicit v2 search identity recovery: %v", err) + } + + var rows int + if err = store.Database().QueryRowContext( + ctx, + "SELECT COUNT(*) FROM code_intel_fts WHERE fts_id = 'v2:replay'", + ).Scan(&rows); err != nil { + t.Fatalf("count explicitly recovered v2 replay: %v", err) + } + if rows != 1 { + t.Fatalf("explicit v2 recovery retained %d replay rows, want 1", rows) + } +} + func TestRepeatedCodeIndexWriteKeepsSearchAndRelationshipsStable(t *testing.T) { ctx := context.Background() store, err := Open(ctx, DefaultDBPath(t.TempDir())) @@ -246,3 +338,12 @@ func openLegacySearchIdentityFixture( return path, database } + +func dropFTSIdentityIndex(t *testing.T, ctx context.Context, database *sql.DB) { + t.Helper() + + _, err := database.ExecContext(ctx, "DROP INDEX idx_code_intel_fts_id_unique") + if err != nil { + t.Fatalf("drop FTS identity index: %v", err) + } +} diff --git a/go/internal/codeintel/store.go b/go/internal/codeintel/store.go index e4b5df9b..989cc501 100644 --- a/go/internal/codeintel/store.go +++ b/go/internal/codeintel/store.go @@ -6,9 +6,11 @@ package codeintel import ( "context" "database/sql" + "errors" "fmt" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -536,7 +538,7 @@ func migrateStore(ctx context.Context, database *sql.DB) error { } } - err := deduplicateSearchIdentity(ctx, database) + err := migrateSearchIdentity(ctx, database) if err != nil { return err } @@ -566,6 +568,65 @@ func migrateStore(ctx context.Context, database *sql.DB) error { return nil } +// migrateSearchIdentity limits the whole-table v1 identity repair to actual +// pre-v2 upgrades. Routine v2 opens rely on the unique indexes; an operator +// performing explicit recovery calls deduplicateSearchIdentity directly. +func migrateSearchIdentity(ctx context.Context, database *sql.DB) error { + version, found, err := storedCodeIntelSchemaVersion(ctx, database) + if err != nil { + return err + } + + if !found || version == schemaVersion { + return nil + } + + if version > schemaVersion { + return fmt.Errorf( + "migrate code intelligence store: schema version %d "+ + "exceeds supported version %d: %w", + version, + schemaVersion, + errStoreMigrationIntegrity, + ) + } + + return deduplicateSearchIdentity(ctx, database) +} + +func storedCodeIntelSchemaVersion( + ctx context.Context, + database *sql.DB, +) (int, bool, error) { + var rawVersion string + + err := database.QueryRowContext( + ctx, + "SELECT value FROM schema_metadata WHERE key = 'schema_version'", + ).Scan(&rawVersion) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + + if err != nil { + return 0, false, fmt.Errorf( + "read code intelligence schema version: %w", + err, + ) + } + + version, err := strconv.Atoi(strings.TrimSpace(rawVersion)) + if err != nil { + return 0, false, fmt.Errorf( + "parse code intelligence schema version %q: %w", + rawVersion, + err, + ) + } + + return version, true, nil +} + type statCountQuery struct { target *int name string diff --git a/go/internal/e2e/git_commit_workflow_test.go b/go/internal/e2e/git_commit_workflow_test.go index 74fa462d..fa03128e 100644 --- a/go/internal/e2e/git_commit_workflow_test.go +++ b/go/internal/e2e/git_commit_workflow_test.go @@ -96,6 +96,7 @@ func TestParentRuntimeCerunGitAddAndCommitUseInstalledArtifacts(t *testing.T) { if err := os.Chmod(parentBin, 0o500); err != nil { t.Fatalf("make parent runtime bin read-only: %v", err) } + assertParentRuntimeExecutableLayout(t, parentBin, parentCerun) add := e2e.Run( t, parentRepo, @@ -109,6 +110,11 @@ func TestParentRuntimeCerunGitAddAndCommitUseInstalledArtifacts(t *testing.T) { if err := os.Chmod(parentBin, 0o700); err != nil { t.Fatalf("restore parent runtime bin permissions: %v", err) } + if activeAgentShellGitBindDenied(add) { + assertParentRuntimePathNotStaged(t, parentRepo, "runtime-add.txt") + + return + } add.RequireExit(t, 0) commit := e2e.Run( @@ -126,6 +132,82 @@ func TestParentRuntimeCerunGitAddAndCommitUseInstalledArtifacts(t *testing.T) { assertNoCommitHeadPolicyLeak(t, commit) } +func activeAgentShellGitBindDenied(result e2e.CommandResult) bool { + return result.Code != 0 && + os.Getenv("CODING_ETHOS_AGENT_SHELL_SANDBOX") == "1" && + os.Getenv("CODING_ETHOS_SANDBOX_ACTIVE") == "1" && + strings.Contains( + strings.ToLower(result.Combined), + "coding-ethos-sandbox: permission denied", + ) +} + +func assertParentRuntimePathNotStaged(t *testing.T, repoRoot, path string) { + t.Helper() + + cached := e2e.Run( + t, + repoRoot, + realGitPath(t), + "diff", + "--cached", + "--name-only", + "--", + path, + ) + cached.RequireExit(t, 0) + if strings.TrimSpace(cached.Stdout) != "" { + t.Fatalf("denied parent runtime git bind staged %s:\n%s", path, cached.Stdout) + } +} + +func assertParentRuntimeExecutableLayout(t *testing.T, parentBin, parentCerun string) { + t.Helper() + + for _, path := range []string{ + parentCerun, + filepath.Join(parentBin, "coding-ethos-run"), + } { + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("inspect parent runtime executable %s: %v", path, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("parent runtime executable is not a regular file: %s", path) + } + } + + binInfo, err := os.Stat(parentBin) + if err != nil { + t.Fatalf("inspect parent runtime bin: %v", err) + } + if binInfo.Mode().Perm() != 0o500 { + t.Fatalf("parent runtime bin mode = %04o, want 0500", binInfo.Mode().Perm()) + } + + sourceSandbox := filepath.Join( + repoRootFromWorkingDirectory(t), + "bin", + "coding-ethos-sandbox", + ) + parentSandbox := filepath.Join(parentBin, "coding-ethos-sandbox") + resolvedSource, err := filepath.EvalSymlinks(sourceSandbox) + if err != nil { + t.Fatalf("resolve source sandbox helper: %v", err) + } + resolvedParent, err := filepath.EvalSymlinks(parentSandbox) + if err != nil { + t.Fatalf("resolve parent sandbox helper: %v", err) + } + if resolvedParent != resolvedSource { + t.Fatalf( + "parent sandbox helper resolves to %s, want %s", + resolvedParent, + resolvedSource, + ) + } +} + func TestParentRuntimeCerunGitAddFailsFastWithoutPolicyBundle(t *testing.T) { fixture := preparedParentRuntimeRepo(t) policyBundle := filepath.Join( @@ -316,7 +398,12 @@ func installParentRuntimeArtifacts(t *testing.T, sourceRoot, parentRepo string) } sourceBinaries = append(sourceBinaries, filepath.Join(sourceRoot, "bin", "cerun")) for _, source := range sourceBinaries { - copyRuntimeFile(t, source, filepath.Join(parentBin, filepath.Base(source))) + destination := filepath.Join(parentBin, filepath.Base(source)) + if filepath.Base(source) == "coding-ethos-sandbox" { + e2e.LinkManagedSandboxHelper(t, source, destination) + continue + } + copyRuntimeFile(t, source, destination) } compile := e2e.Run( diff --git a/go/internal/e2e/mcp_test.go b/go/internal/e2e/mcp_test.go index aa069f44..e694986d 100644 --- a/go/internal/e2e/mcp_test.go +++ b/go/internal/e2e/mcp_test.go @@ -20,7 +20,9 @@ import ( "blackcat.ca/coding-ethos/go/internal/e2e" ) -const mcpClientTimeout = 30 * time.Second +// The client spans the complete multi-request workflow; individual command +// deadlines remain independently bounded by the shared e2e command timeout. +const mcpClientTimeout = 2 * time.Minute type MCPClient struct { cancel context.CancelFunc diff --git a/go/internal/e2e/process_unix.go b/go/internal/e2e/process_unix.go index 9dfed605..550bab60 100644 --- a/go/internal/e2e/process_unix.go +++ b/go/internal/e2e/process_unix.go @@ -7,6 +7,8 @@ package e2e import ( "errors" + "fmt" + "os" "os/exec" "syscall" ) @@ -15,13 +17,26 @@ func configureCommandProcessGroup(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } -func terminateCommandProcessGroup(cmd *exec.Cmd) { +func configureCommandCancellation(cmd *exec.Cmd) { + cmd.Cancel = func() error { + return terminateCommandProcessGroup(cmd) + } + cmd.WaitDelay = commandWaitDelay +} + +func terminateCommandProcessGroup(cmd *exec.Cmd) error { if cmd.Process == nil { - return + return os.ErrProcessDone } err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - if err != nil && !errors.Is(err, syscall.ESRCH) { - return + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone } + + if err != nil { + return fmt.Errorf("kill command process group %d: %w", cmd.Process.Pid, err) + } + + return nil } diff --git a/go/internal/e2e/process_unix_test.go b/go/internal/e2e/process_unix_test.go new file mode 100644 index 00000000..db10a4ce --- /dev/null +++ b/go/internal/e2e/process_unix_test.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build !windows + +package e2e + +import ( + "bufio" + "bytes" + "context" + "errors" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +const ( + processGroupHelperMode = "CODING_ETHOS_E2E_PROCESS_GROUP_HELPER" + processGroupReadyFD = "CODING_ETHOS_E2E_PROCESS_GROUP_READY_FD" + processGroupChildPID = "child-pid=" +) + +func TestCommandCancellationTerminatesProcessGroup(t *testing.T) { + if mode := os.Getenv(processGroupHelperMode); mode != "" { + runProcessGroupHelper(t, mode) + + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cmd := exec.CommandContext( + ctx, + os.Args[0], + "-test.run=^TestCommandCancellationTerminatesProcessGroup$", + ) + readyReader, readyWriter, err := os.Pipe() + if err != nil { + t.Fatalf("create process-group readiness pipe: %v", err) + } + defer func() { _ = readyReader.Close() }() + + cmd.Env = append( + os.Environ(), + processGroupHelperMode+"=parent", + processGroupReadyFD+"=3", + ) + cmd.ExtraFiles = []*os.File{readyWriter} + configureCommandProcessGroup(cmd) + configureCommandCancellation(cmd) + + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + + if err := cmd.Start(); err != nil { + _ = readyWriter.Close() + t.Fatalf("start process-group helper: %v", err) + } + if err := readyWriter.Close(); err != nil { + cancel() + _ = cmd.Wait() + t.Fatalf("close process-group readiness writer: %v", err) + } + + if err := readyReader.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil { + cancel() + _ = cmd.Wait() + t.Fatalf("bound process-group readiness read: %v", err) + } + line, err := bufio.NewReader(readyReader).ReadString('\n') + if err != nil { + cancel() + _ = cmd.Wait() + t.Fatalf("process-group helper did not become ready: %v\n%s", err, &output) + } + childPID := processGroupHelperChildPID(t, line) + + started := time.Now() + cancel() + err = cmd.Wait() + elapsed := time.Since(started) + if err == nil || !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("canceled process group returned err=%v context=%v", err, ctx.Err()) + } + if elapsed >= time.Second { + t.Fatalf("ready process-group cancellation took %s", elapsed) + } + + assertProcessExited(t, childPID) +} + +func runProcessGroupHelper(t *testing.T, mode string) { + t.Helper() + + if mode == "child" { + time.Sleep(30 * time.Second) + + return + } + if mode != "parent" { + t.Fatalf("unknown process-group helper mode %q", mode) + } + readyDescriptor, err := strconv.Atoi(os.Getenv(processGroupReadyFD)) + if err != nil { + t.Fatalf("parse process-group readiness descriptor: %v", err) + } + ready := os.NewFile(uintptr(readyDescriptor), "process-group-ready") + if ready == nil { + t.Fatal("open process-group readiness descriptor") + } + defer func() { _ = ready.Close() }() + syscall.CloseOnExec(readyDescriptor) + + child := exec.Command( + os.Args[0], + "-test.run=^TestCommandCancellationTerminatesProcessGroup$", + ) + child.Env = append(os.Environ(), processGroupHelperMode+"=child") + child.Stdout = os.Stdout + child.Stderr = os.Stderr + if err := child.Start(); err != nil { + t.Fatalf("start process-group child: %v", err) + } + if _, err := ready.WriteString( + processGroupChildPID + strconv.Itoa(child.Process.Pid) + "\n", + ); err != nil { + t.Fatalf("write process-group readiness: %v", err) + } + if err := ready.Close(); err != nil { + t.Fatalf("close process-group readiness: %v", err) + } + time.Sleep(30 * time.Second) +} + +func processGroupHelperChildPID(t *testing.T, line string) int { + t.Helper() + + value, found := strings.CutPrefix(strings.TrimSpace(line), processGroupChildPID) + if !found { + t.Fatalf("invalid process-group readiness line %q", line) + } + pid, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("parse process-group child PID %q: %v", value, err) + } + + return pid +} + +func assertProcessExited(t *testing.T, pid int) { + t.Helper() + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + err := syscall.Kill(pid, 0) + if errors.Is(err, syscall.ESRCH) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("process-group child %d survived cancellation", pid) +} diff --git a/go/internal/e2e/process_windows.go b/go/internal/e2e/process_windows.go index af9969d2..0dc7a2be 100644 --- a/go/internal/e2e/process_windows.go +++ b/go/internal/e2e/process_windows.go @@ -5,8 +5,27 @@ package e2e -import "os/exec" +import ( + "errors" + "os" + "os/exec" +) func configureCommandProcessGroup(_ *exec.Cmd) {} -func terminateCommandProcessGroup(_ *exec.Cmd) {} +func configureCommandCancellation(cmd *exec.Cmd) { + cmd.WaitDelay = commandWaitDelay +} + +func terminateCommandProcessGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return os.ErrProcessDone + } + + err := cmd.Process.Kill() + if errors.Is(err, os.ErrProcessDone) { + return os.ErrProcessDone + } + + return err +} diff --git a/go/internal/e2e/scenario.go b/go/internal/e2e/scenario.go index 45ba92c0..c1391b75 100644 --- a/go/internal/e2e/scenario.go +++ b/go/internal/e2e/scenario.go @@ -24,6 +24,7 @@ import ( const ( commandTimeout = 30 * time.Second + commandWaitDelay = time.Second e2eDirMode = 0o700 e2eFileMode = 0o600 optionalVenvRuntimeEntry = ".venv" @@ -206,7 +207,7 @@ func copyMutableRuntimeBin(t *testing.T, sourceRoot, runtimeRoot string) { target := filepath.Join(binRoot, filepath.Base(source)) if filepath.Base(source) == "coding-ethos-sandbox" { - linkManagedSandboxHelper(t, source, target) + LinkManagedSandboxHelper(t, source, target) continue } @@ -296,7 +297,7 @@ func buildInstrumentedEthosRoot(t *testing.T, ethosRoot string) string { buildInstrumentedCommand(t, ethosRoot, runtimeRoot, command) } - linkManagedSandboxHelper( + LinkManagedSandboxHelper( t, filepath.Join(ethosRoot, "bin", "coding-ethos-sandbox"), filepath.Join(runtimeRoot, "bin", "coding-ethos-sandbox"), @@ -305,11 +306,11 @@ func buildInstrumentedEthosRoot(t *testing.T, ethosRoot string) string { return runtimeRoot } -// linkManagedSandboxHelper preserves the exact repository-managed executable +// LinkManagedSandboxHelper preserves the exact repository-managed executable // path that host AppArmor policy authorizes for Linux namespace creation. // Instrumenting or copying the helper changes that path and makes an otherwise // valid nested sandbox fail closed before it can apply its policy. -func linkManagedSandboxHelper(t *testing.T, source, destination string) { +func LinkManagedSandboxHelper(t *testing.T, source, destination string) { t.Helper() resolved, err := filepath.EvalSymlinks(source) @@ -617,6 +618,7 @@ func runCommand( cmd.Env = commandEnvironmentWith(t, overrides) cmd.Stdin = strings.NewReader(input) configureCommandProcessGroup(cmd) + configureCommandCancellation(cmd) var ( stdout bytes.Buffer @@ -633,7 +635,15 @@ func runCommand( var exitErr *exec.ExitError switch { case ctx.Err() != nil: - terminateCommandProcessGroup(cmd) + terminationErr := terminateCommandProcessGroup(cmd) + if terminationErr != nil && !errors.Is(terminationErr, os.ErrProcessDone) { + t.Fatalf( + "terminate timed-out command %q: %v", + strings.Join(args, " "), + terminationErr, + ) + } + t.Fatalf("command timed out: %s", strings.Join(args, " ")) case errors.As(err, &exitErr): code = exitErr.ExitCode() diff --git a/go/internal/evaluators/cel_expression.go b/go/internal/evaluators/cel_expression.go index 68a61b74..302041a9 100644 --- a/go/internal/evaluators/cel_expression.go +++ b/go/internal/evaluators/cel_expression.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" + "github.com/google/cel-go/cel" + "blackcat.ca/coding-ethos/go/diagnostics" "blackcat.ca/coding-ethos/go/internal/apperror" "blackcat.ca/coding-ethos/go/internal/celexpr" @@ -74,16 +76,20 @@ func EvaluateCELExpression( activation := celActivation(context, source) - output, _, err := program.Eval(activation) + matched, err := evaluateCELBool(program, activation) if err != nil { return nil, fmt.Errorf("evaluate CEL expression: %w", err) } - matched, ok := output.Value().(bool) - if !ok || !matched { + if !matched { return nil, nil } + diffLineWitness, err := isolatedDiffLineWitness(program, activation) + if err != nil { + return nil, fmt.Errorf("locate matched CEL diff line: %w", err) + } + decisionMode := strings.TrimSpace(policyDef.DefaultSeverity) if decisionMode == "" { decisionMode = blockDecision @@ -118,6 +124,7 @@ func EvaluateCELExpression( decisionMode, source, activation, + diffLineWitness, )} return []policy.Decision{decision}, nil @@ -129,6 +136,7 @@ func celDiagnostic( decisionMode string, source string, activation map[string]any, + diffLineWitness celDiffLineWitness, ) diagnostics.Diagnostic { diagnostic := diagnostics.Diagnostic{ Tool: "policy", @@ -151,45 +159,194 @@ func celDiagnostic( return diagnostic } - if len(context.Files) == 1 { - diagnostic.File = context.Files[0] - } - - if policyDef.ID == filesystemLineLimitsPolicy { + switch policyDef.ID { + case filesystemLineLimitsPolicy: applyLineLimitFileDiagnostic( &diagnostic, activation, context.EvaluatorOptions, ) + case hookChangedFileScopePolicy: + applyHookCommandDiagnostic(&diagnostic, activation) + case similarCodeDetectedPolicy: + applySimilarityDiagnostic(&diagnostic, activation) + case pythonSuppressionWritePolicy: + applyPythonSuppressionDiagnostic( + &diagnostic, + activation, + context.EvaluatorOptions, + ) + default: + if strings.Contains(source, "changed_symbols") || + strings.Contains(source, "proposed_symbol_changes") { + applyGrowingSymbolDiagnostic(&diagnostic, activation) + } + } - return diagnostic + if diagnostic.File == "" && diffLineWitness.found { + diagnostic.File = diffLineWitness.line.File + diagnostic.Line = int(diffLineWitness.line.Line) + diagnostic.Detail = diffLineWitness.line.Text + diagnostic.Metadata["diff_change_source"] = diffLineWitness.changeSource } - if policyDef.ID == hookChangedFileScopePolicy { - applyHookCommandDiagnostic(&diagnostic, activation) + if diagnostic.File == "" && len(context.Files) == 1 { + diagnostic.File = context.Files[0] + } - return diagnostic + return diagnostic +} + +type celDiffLineWitness struct { + changeSource string + line celexpr.DiffLineInput + found bool +} + +func evaluateCELBool(program cel.Program, activation map[string]any) (bool, error) { + output, _, err := program.Eval(activation) + if err != nil { + return false, fmt.Errorf("evaluate CEL program: %w", err) } - if policyDef.ID == similarCodeDetectedPolicy { - applySimilarityDiagnostic(&diagnostic, activation) + matched, ok := output.Value().(bool) - return diagnostic + return ok && matched, nil +} + +func isolatedDiffLineWitness( + program cel.Program, + activation map[string]any, +) (celDiffLineWitness, error) { + diff, ok := activation["diff"].(celexpr.DiffInput) + if !ok || len(diff.AddedLines)+len(diff.RemovedLines) == 0 { + return celDiffLineWitness{}, nil } - if policyDef.ID == pythonSuppressionWritePolicy { - applyPythonSuppressionDiagnostic( - &diagnostic, - activation, - context.EvaluatorOptions, + emptyDiff := emptyDiffLineView(diff) + emptyActivation := maps.Clone(activation) + emptyActivation["diff"] = emptyDiff + + matched, err := evaluateCELBool(program, emptyActivation) + if err != nil { + return celDiffLineWitness{}, err + } + + if matched { + return celDiffLineWitness{}, nil + } + + candidates := make( + []celDiffLineWitness, + 0, + len(diff.AddedLines)+len(diff.RemovedLines), + ) + for _, line := range diff.AddedLines { + candidates = append(candidates, celDiffLineWitness{ + changeSource: "added", + line: line, + found: true, + }) + } + + for _, line := range diff.RemovedLines { + candidates = append(candidates, celDiffLineWitness{ + changeSource: "removed", + line: line, + found: true, + }) + } + + var witness celDiffLineWitness + + for index := range candidates { + candidate := candidates[index] + candidateActivation := maps.Clone(activation) + candidateActivation["diff"] = oneDiffLineView( + emptyDiff, + diff, + candidate.line, + candidate.changeSource == "added", ) - return diagnostic + matched, err = evaluateCELBool(program, candidateActivation) + if err != nil { + return celDiffLineWitness{}, err + } + + if !matched { + continue + } + + if witness.found { + return celDiffLineWitness{}, nil + } + + witness = candidate } - applyGrowingSymbolDiagnostic(&diagnostic, activation) + return witness, nil +} - return diagnostic +func emptyDiffLineView(diff celexpr.DiffInput) celexpr.DiffInput { + empty := diff + empty.AddedLines = []celexpr.DiffLineInput{} + empty.RemovedLines = []celexpr.DiffLineInput{} + empty.Hunks = make([]celexpr.DiffHunkInput, len(diff.Hunks)) + + for index, hunk := range diff.Hunks { + empty.Hunks[index] = hunk + empty.Hunks[index].AddedLines = []celexpr.DiffLineInput{} + empty.Hunks[index].RemovedLines = []celexpr.DiffLineInput{} + } + + return empty +} + +func oneDiffLineView( + empty celexpr.DiffInput, + original celexpr.DiffInput, + line celexpr.DiffLineInput, + added bool, +) celexpr.DiffInput { + view := emptyDiffLineView(empty) + if added { + view.AddedLines = []celexpr.DiffLineInput{line} + } else { + view.RemovedLines = []celexpr.DiffLineInput{line} + } + + for hunkIndex, hunk := range original.Hunks { + lines := hunk.RemovedLines + if added { + lines = hunk.AddedLines + } + + for _, hunkLine := range lines { + if !sameDiffLine(line, hunkLine) { + continue + } + + if added { + view.Hunks[hunkIndex].AddedLines = []celexpr.DiffLineInput{line} + } else { + view.Hunks[hunkIndex].RemovedLines = []celexpr.DiffLineInput{line} + } + + return view + } + } + + return view +} + +func sameDiffLine(left, right celexpr.DiffLineInput) bool { + return left.File == right.File && + left.Text == right.Text && + left.Line == right.Line && + left.NewLine == right.NewLine && + left.OldLine == right.OldLine && + left.IsBlank == right.IsBlank } func applyPythonSuppressionDiagnostic( diff --git a/go/internal/evaluators/cel_expression_test.go b/go/internal/evaluators/cel_expression_test.go index 4e80c011..9f69dead 100644 --- a/go/internal/evaluators/cel_expression_test.go +++ b/go/internal/evaluators/cel_expression_test.go @@ -404,6 +404,82 @@ func TestEvaluateCELExpressionBlocksAgentBrandingInStagedMarkdown(t *testing.T) } } +func TestEvaluateCELExpressionDiffRuleLocatesMatchedLine(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + runCELGit(t, repo, "init") + runCELGit(t, repo, "config", "user.email", "test@example.com") + runCELGit(t, repo, "config", "user.name", "Test User") + + unrelatedFile := filepath.Join(repo, "a_unrelated.go") + matchedFile := filepath.Join(repo, "z_matched.go") + + if err := os.WriteFile( + unrelatedFile, + []byte("package sample\n\nfunc unrelated() {}\n"), + 0o600, + ); err != nil { + t.Fatalf("write unrelated source: %v", err) + } + if err := os.WriteFile( + matchedFile, + []byte("package sample\n\nfunc target() {}\n"), + 0o600, + ); err != nil { + t.Fatalf("write matched source: %v", err) + } + runCELGit(t, repo, "add", "a_unrelated.go", "z_matched.go") + runCELGit(t, repo, "commit", "-m", "initial") + + if err := os.WriteFile( + unrelatedFile, + []byte("package sample\n\nfunc unrelated() {\n\tprintln(\"grows\")\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("grow unrelated source: %v", err) + } + if err := os.WriteFile( + matchedFile, + []byte("package sample\n\nfunc target() {\n\t"+ + "fmt."+"Fprintf(nil, \"blocked\")\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("write matched diff line: %v", err) + } + runCELGit(t, repo, "add", "a_unrelated.go", "z_matched.go") + + policyDef := celExpressionPolicy() + policyDef.ID = "feedback.route" + + decisions, err := EvaluateCELExpression( + policyDef, + Context{ + Cwd: repo, + Files: []string{"a_unrelated.go", "z_matched.go"}, + StagedFiles: []string{"a_unrelated.go", "z_matched.go"}, + Scope: "staged", + EvaluatorOptions: map[string]any{ + "when": `diff.added_lines.exists(line, + line.file == "z_matched.go" && + line.text.contains("fmt." + "Fprintf") + )`, + }, + }, + ) + if err != nil { + t.Fatalf("evaluate CEL expression: %v", err) + } + + if len(decisions) != 1 { + t.Fatalf("decisions = %#v, want one block", decisions) + } + if diagnostic := decisions[0].Diagnostics[0]; diagnostic.File != "z_matched.go" || + diagnostic.Line != 4 { + t.Fatalf("diagnostic = %#v, want z_matched.go:4", diagnostic) + } +} + func TestEvaluateCELExpressionBlocksAgentBrandingInCommitMessage(t *testing.T) { t.Parallel() diff --git a/go/internal/evaluators/shell_best_practices.go b/go/internal/evaluators/shell_best_practices.go index 2964bac0..17b054a4 100644 --- a/go/internal/evaluators/shell_best_practices.go +++ b/go/internal/evaluators/shell_best_practices.go @@ -104,8 +104,16 @@ func repositoryHasTrackedCommonShellHelper( return false, errShellHelperWorkingDirectoryRequired } + repositoryRoot, err := gitWorktreeRoot(cwd) + if err != nil { + return false, fmt.Errorf( + "resolve repository root for common shell helpers: %w", + err, + ) + } + helperPaths, err := configuredCommonShellHelperPaths( - cwd, + repositoryRoot, requireCommonForPrefixes, ) if err != nil { @@ -121,7 +129,7 @@ func repositoryHasTrackedCommonShellHelper( args = append(args, ":(literal)"+helperPath) } - output, err := GitCommand(cwd, args...).CombinedOutput() + output, err := GitCommand(repositoryRoot, args...).CombinedOutput() if err != nil { return false, fmt.Errorf( "inspect tracked common shell helpers with git ls-files: %w: %s", diff --git a/go/internal/evaluators/shell_best_practices_test.go b/go/internal/evaluators/shell_best_practices_test.go index 5460a15d..679c6bcb 100644 --- a/go/internal/evaluators/shell_best_practices_test.go +++ b/go/internal/evaluators/shell_best_practices_test.go @@ -173,7 +173,7 @@ func TestEvaluateShellBestPracticesPropagatesGitInspectionFailure(t *testing.T) if err == nil { t.Fatal("git inspection failure must fail helper validation") } - if !strings.Contains(err.Error(), "git ls-files") { + if !strings.Contains(err.Error(), "git worktree root") { t.Fatalf("git inspection error = %q", err) } } @@ -273,6 +273,57 @@ func TestEvaluateShellBestPracticesRequiresOnlyExistingCommonHelper(t *testing.T } } +func TestEvaluateShellBestPracticesResolvesCommonHelperFromNestedCWD( + t *testing.T, +) { + t.Parallel() + + repo := t.TempDir() + initializeStagedAdminGitRepo(t, repo) + scriptsDir := filepath.Join(repo, "scripts") + nestedDir := filepath.Join(repo, "work", "nested") + for _, directory := range []string{scriptsDir, nestedDir} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatalf("create fixture directory %s: %v", directory, err) + } + } + + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + commonPath := filepath.Join(scriptsDir, "common.sh") + if err := os.WriteFile(commonPath, content, 0o600); err != nil { + t.Fatalf("write common helper: %v", err) + } + runGit(t, repo, "add", "scripts/common.sh") + + scriptPath := filepath.Join(scriptsDir, "work.sh") + if err := os.WriteFile(scriptPath, content, 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + decisions, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{ + Cwd: nestedDir, + Files: []string{scriptPath}, + EvaluatorOptions: map[string]any{ + "require_common_for_prefixes": []any{ + scriptsDir + string(filepath.Separator), + }, + }, + }, + ) + if err != nil { + t.Fatalf("evaluate common helper from nested working directory: %v", err) + } + if len(decisions) != 1 { + t.Fatalf("nested working directory decisions = %#v", decisions) + } + if got := decisions[0].Diagnostics[0].Message; got != + "scripts/ shell files must source the repository common shell helpers" { + t.Fatalf("diagnostic message = %q", got) + } +} + func shellBestPracticesPolicy() policy.Policy { return policy.Policy{ ID: "shell.best_practices", diff --git a/go/internal/hookrunnercli/external_tool.go b/go/internal/hookrunnercli/external_tool.go index 01c3ebad..ff49cbbf 100644 --- a/go/internal/hookrunnercli/external_tool.go +++ b/go/internal/hookrunnercli/external_tool.go @@ -185,7 +185,7 @@ func externalToolEnv(request externalToolRequest) ([]string, error) { env := make([]string, 0, len(os.Environ())+len(request.Env)) hasPath := false - root := repoRoot() + root := externalToolStateRoot() cacheEnv, err := externalToolCacheEnv(root) if err != nil { @@ -232,6 +232,14 @@ func externalToolEnv(request externalToolRequest) ([]string, error) { return append(env, request.Env...), nil } +func externalToolStateRoot() string { + if root := strings.TrimSpace(os.Getenv(stateRootEnv)); root != "" { + return filepath.Clean(root) + } + + return repoRoot() +} + type externalToolCacheEnvironment struct { GoTemp string GoCache string diff --git a/go/internal/hookrunnercli/external_tool_internal_test.go b/go/internal/hookrunnercli/external_tool_internal_test.go index 636b595d..473f050e 100644 --- a/go/internal/hookrunnercli/external_tool_internal_test.go +++ b/go/internal/hookrunnercli/external_tool_internal_test.go @@ -115,6 +115,7 @@ func TestExternalToolEnvRemovesGitHookLocalEnvironment(t *testing.T) { t.Setenv("CODING_ETHOS_AGENT_SHELL_SANDBOX", "1") t.Setenv("CODING_ETHOS_SANDBOX_ACTIVE", "1") t.Setenv("CODING_ETHOS_SANDBOX_ROOT", repo) + t.Setenv(stateRootEnv, repo) env, err := externalToolEnv(externalToolRequest{ Dir: repo, @@ -241,6 +242,7 @@ func TestExternalToolEnvIsolatesActiveUVProjects(t *testing.T) { t.Chdir(repo) t.Setenv(consumerRootEnv, repo) + t.Setenv(stateRootEnv, repo) firstEnv, err := externalToolEnv(externalToolRequest{ Dir: firstProject, @@ -289,6 +291,49 @@ func TestExternalToolEnvIsolatesActiveUVProjects(t *testing.T) { } } +func TestExternalToolEnvUsesStateRootOutsideConsumerCheckout(t *testing.T) { + consumerRoot := t.TempDir() + stateRoot := t.TempDir() + hookRoot := t.TempDir() + hookProject := filepath.Join(hookRoot, "pre-commit", "hooks") + mustWriteTestFile( + t, + filepath.Join(hookProject, "pyproject.toml"), + "[project]\nname = 'fixture'\n", + ) + + t.Chdir(hookRoot) + t.Setenv(consumerRootEnv, consumerRoot) + t.Setenv(stateRootEnv, stateRoot+string(os.PathSeparator)+".") + + env, err := externalToolEnv(externalToolRequest{ + Dir: hookProject, + Command: []string{ + "uv", "run", "--project", hookProject, "pyupgrade", + }, + }) + if err != nil { + t.Fatalf("externalToolEnv: %v", err) + } + + projectEnv := externalToolTestRequiredEnvValue( + t, + env, + "UV_PROJECT_ENVIRONMENT", + ) + wantRoot := filepath.Join(stateRoot, ".coding-ethos", "cache", "uv-project-env") + if !strings.HasPrefix(projectEnv, wantRoot+string(os.PathSeparator)) { + t.Fatalf("UV project environment %q is outside state root %q", projectEnv, wantRoot) + } + if strings.HasPrefix(projectEnv, hookProject+string(os.PathSeparator)) { + t.Fatalf( + "UV project environment %q is inside hook project %q", + projectEnv, + hookProject, + ) + } +} + func TestExternalToolEnvFreezesOnlySealedOrExplicitUVProjects(t *testing.T) { repo := t.TempDir() sealedProject := filepath.Join(repo, "sealed") @@ -330,6 +375,7 @@ func TestExternalToolEnvFreezesOnlySealedOrExplicitUVProjects(t *testing.T) { t.Chdir(repo) t.Setenv(consumerRootEnv, repo) + t.Setenv(stateRootEnv, repo) sealedEnv, err := externalToolEnv(externalToolRequest{ Dir: sealedProject, diff --git a/go/internal/hookrunnercli/main.go b/go/internal/hookrunnercli/main.go index cbaa76d1..fcb33ec0 100644 --- a/go/internal/hookrunnercli/main.go +++ b/go/internal/hookrunnercli/main.go @@ -29,6 +29,7 @@ const ( configEnv = "CODE_ETHOS_PRECOMMIT_CONFIG" precommitRootEnv = "CODE_ETHOS_PRECOMMIT_ROOT" consumerRootEnv = "CODE_ETHOS_CONSUMER_ROOT" + stateRootEnv = "CODE_ETHOS_STATE_ROOT" localRootEnv = "CODE_ETHOS_LOCAL_ROOT" privateKeyPattern = `-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----` textChunkSize = 8192 diff --git a/go/internal/hooks/gate_exit_status.go b/go/internal/hooks/gate_exit_status.go index b2f1aa89..8dd46e9d 100644 --- a/go/internal/hooks/gate_exit_status.go +++ b/go/internal/hooks/gate_exit_status.go @@ -52,6 +52,27 @@ type gateShell struct { operators []string } +type pipefailSetting uint8 + +const ( + pipefailUnchanged pipefailSetting = iota + pipefailEnabled + pipefailDisabled +) + +func (setting pipefailSetting) apply(current bool) bool { + switch setting { + case pipefailUnchanged: + return current + case pipefailEnabled: + return true + case pipefailDisabled: + return false + default: + return current + } +} + func maskedRequiredGateStatus( command string, inheritedPipefail bool, @@ -78,9 +99,10 @@ func maskedRequiredGateStatus( return reason, true } - if isPipefailCommand(gateExecutableArgv(parsed.segments[index])) { - pipefail = true - } + setting := pipefailCommandSetting( + gateExecutableArgv(parsed.segments[index]), + ) + pipefail = setting.apply(pipefail) } return "", false @@ -97,7 +119,7 @@ func (parsed gateShell) maskedSegmentStatus( if nestedFound { reason, masked := maskedRequiredGateStatus( nested, - pipefail || nestedPipefail, + nestedPipefail.apply(pipefail), depth+1, ) if masked { @@ -180,9 +202,19 @@ func parseGateShell(command string) (gateShell, error) { return parsed, nil } -func isPipefailCommand(argv []string) bool { - return len(argv) >= 3 && argv[0] == "set" && - argv[1] == "-o" && argv[2] == "pipefail" +func pipefailCommandSetting(argv []string) pipefailSetting { + if len(argv) < 3 || argv[0] != "set" || argv[2] != "pipefail" { + return pipefailUnchanged + } + + switch argv[1] { + case "-o": + return pipefailEnabled + case "+o": + return pipefailDisabled + default: + return pipefailUnchanged + } } func sequenceAfter(gateIndex int, operators []string) bool { @@ -425,17 +457,23 @@ func discardWrapperOperand(argv []string) []string { return argv[1:] } -func nestedShellScript(argv []string) (string, bool, bool) { +func nestedShellScript(argv []string) (string, pipefailSetting, bool) { if len(argv) == 0 || !shellExecutable(argv[0]) { - return "", false, false + return "", pipefailUnchanged, false } - pipefail := false + pipefail := pipefailUnchanged for index := 1; index < len(argv); index++ { - if argv[index] == "-o" && index+1 < len(argv) && + if (argv[index] == "-o" || argv[index] == "+o") && + index+1 < len(argv) && argv[index+1] == "pipefail" { - pipefail = true + if argv[index] == "-o" { + pipefail = pipefailEnabled + } else { + pipefail = pipefailDisabled + } + index++ continue diff --git a/go/internal/hooks/gate_exit_status_test.go b/go/internal/hooks/gate_exit_status_test.go index 0acd9053..6d75523c 100644 --- a/go/internal/hooks/gate_exit_status_test.go +++ b/go/internal/hooks/gate_exit_status_test.go @@ -28,6 +28,8 @@ func TestRequiredGateExitStatusBlocksMaskedFailures(t *testing.T) { "make lint | tee lint.log", "make purrdf-extractor-check | tee purrdf.log", "make check | tail -100 && set -o pipefail", + "make check &", + "bash -c 'make check &'", } { route := requiredGateExitStatusRouteFor(Event{ HookEventName: eventPreToolUse, @@ -42,6 +44,122 @@ func TestRequiredGateExitStatusBlocksMaskedFailures(t *testing.T) { } } +func TestRequiredGateExitStatusTracksPipefailInExecutionOrder(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + command string + inherited bool + masked bool + }{ + { + name: "disabled by default", + command: "make check | tail -1", + masked: true, + }, + { + name: "inherited enabled", + command: "make check | tail -1", + inherited: true, + }, + { + name: "enabled before gate", + command: "set -o pipefail; make check | tail -1", + }, + { + name: "disabled before gate", + command: "set +o pipefail; make check | tail -1", + inherited: true, + masked: true, + }, + { + name: "disable then enable", + command: "set +o pipefail; set -o pipefail; make check | tail -1", + inherited: true, + }, + { + name: "enable then disable", + command: "set -o pipefail; set +o pipefail; make check | tail -1", + inherited: true, + masked: true, + }, + { + name: "later enable is not retroactive", + command: "make check | tail -1; set -o pipefail", + masked: true, + }, + { + name: "unrelated enable preserves inherited state", + command: "set -o nounset; make check | tail -1", + inherited: true, + }, + { + name: "unrelated enable preserves disabled state", + command: "set -o nounset; make check | tail -1", + masked: true, + }, + { + name: "nested explicit disable overrides inherited state", + command: "bash +o pipefail -c 'make check | tail -1'", + inherited: true, + masked: true, + }, + { + name: "nested explicit enable", + command: "bash -o pipefail -c 'make check | tail -1'", + }, + { + name: "nested disable then enable", + command: "bash +o pipefail -o pipefail -c 'make check | tail -1'", + }, + { + name: "nested enable then disable", + command: "bash -o pipefail +o pipefail -c 'make check | tail -1'", + inherited: true, + masked: true, + }, + { + name: "outer enable reaches nested command", + command: "set -o pipefail; bash -c 'make check | tail -1'", + }, + { + name: "nested unrelated option preserves inherited state", + command: "bash -o nounset -c 'make check | tail -1'", + inherited: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + reason, masked := maskedRequiredGateStatus( + test.command, + test.inherited, + 0, + ) + if masked != test.masked { + t.Fatalf( + "command %q masked = %t, want %t (reason %q)", + test.command, + masked, + test.masked, + reason, + ) + } + if masked && reason != gatePipelineReason { + t.Fatalf( + "command %q reason = %q, want %q", + test.command, + reason, + gatePipelineReason, + ) + } + }) + } +} + func TestRequiredGateExitStatusFailsClosedBeyondShellDepth(t *testing.T) { t.Parallel() diff --git a/go/internal/lint/runner_test.go b/go/internal/lint/runner_test.go index cae6fd7c..8de0bbce 100644 --- a/go/internal/lint/runner_test.go +++ b/go/internal/lint/runner_test.go @@ -409,6 +409,11 @@ func initializedLintGitRepo(t *testing.T) string { repo := t.TempDir() runLintGit(t, repo, "init") + hooksPath := filepath.Join(repo, ".git", "test-hooks") + if err := os.Mkdir(hooksPath, 0o700); err != nil { + t.Fatalf("create isolated Git hooks directory: %v", err) + } + runLintGit(t, repo, "config", "core.hooksPath", hooksPath) runLintGit(t, repo, "config", "user.email", "test@example.com") runLintGit(t, repo, "config", "user.name", "Test") runLintGit(t, repo, "config", "commit.gpgsign", "false") diff --git a/go/internal/managedcapture/capture.go b/go/internal/managedcapture/capture.go index fd58a106..b4a870d8 100644 --- a/go/internal/managedcapture/capture.go +++ b/go/internal/managedcapture/capture.go @@ -64,6 +64,7 @@ type captureRequest struct { Parser string Category string ToolPath string + UVProject string Cwd string TraceRoot string SandboxBackendPath string diff --git a/go/internal/managedcapture/managed_capture.go b/go/internal/managedcapture/managed_capture.go index 2b980682..4b24b019 100644 --- a/go/internal/managedcapture/managed_capture.go +++ b/go/internal/managedcapture/managed_capture.go @@ -74,8 +74,9 @@ type Options struct { } type managedToolCommand struct { - Path string - Prefix []string + Path string + UVProject string + Prefix []string } func Run(options Options) int { @@ -195,6 +196,7 @@ func managedCaptureRequest( Parser: firstCaptureNonEmpty(tool.Parser, options.Tool), Category: tool.Category, ToolPath: command.Path, + UVProject: command.UVProject, ToolPrefix: command.Prefix, Cwd: captureCwd, TraceRoot: options.ConsumerRoot, @@ -858,15 +860,18 @@ func managedToolCommandFor(tool toolcatalog.Tool, ethosRoot string) managedToolC return managedToolCommand{} } + project := filepath.Join(ethosRoot, "pre-commit", "hooks") + return managedToolCommand{ Path: uvBin, Prefix: []string{ "run", "--quiet", "--project", - filepath.Join(ethosRoot, "pre-commit", "hooks"), + project, runtime.Command[0], }, + UVProject: project, } } diff --git a/go/internal/managedcapture/sandbox_cache_env.go b/go/internal/managedcapture/sandbox_cache_env.go index 92092a07..bbd0689a 100644 --- a/go/internal/managedcapture/sandbox_cache_env.go +++ b/go/internal/managedcapture/sandbox_cache_env.go @@ -5,6 +5,8 @@ package managedcapture import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" @@ -34,8 +36,9 @@ type sandboxCacheEnvironment struct { // sandbox without network access, and an installation away from its // default path is invisible to a command that inherits no environment — // cargo is on PATH, runs, and reports no toolchain. - CargoHome string - RustupHome string + CargoHome string + RustupHome string + UVProjectEnvironment string // Trailing so the single bool pads the struct once rather than splitting // the string fields around it. CleanupTemp bool @@ -109,20 +112,21 @@ func (environment sandboxCacheEnvironment) items() []string { func (environment sandboxCacheEnvironment) value(name string) string { return map[string]string{ - "TMPDIR": environment.TempDir, - "XDG_RUNTIME_DIR": environment.RuntimeDir, - "GOCACHE": environment.GoCache, - "GOPATH": environment.GoPath, - "GOMODCACHE": environment.GoModCache, - "GOLANGCI_LINT_CACHE": environment.GolangCILintDir, - "GOROOT": environment.GoRoot, - "CGO_ENABLED": environment.CGOEnabled, - "CC": environment.CC, - "COMPILER_PATH": environment.CompilerPath, - "AS": environment.Assembler, - "CARGO_HOME": environment.CargoHome, - "RUSTUP_HOME": environment.RustupHome, - evaluators.RealGitEnv: environment.RealGit, + "TMPDIR": environment.TempDir, + "XDG_RUNTIME_DIR": environment.RuntimeDir, + "GOCACHE": environment.GoCache, + "GOPATH": environment.GoPath, + "GOMODCACHE": environment.GoModCache, + "GOLANGCI_LINT_CACHE": environment.GolangCILintDir, + "GOROOT": environment.GoRoot, + "CGO_ENABLED": environment.CGOEnabled, + "CC": environment.CC, + "COMPILER_PATH": environment.CompilerPath, + "AS": environment.Assembler, + "CARGO_HOME": environment.CargoHome, + "RUSTUP_HOME": environment.RustupHome, + "UV_PROJECT_ENVIRONMENT": environment.UVProjectEnvironment, + evaluators.RealGitEnv: environment.RealGit, }[name] } @@ -141,6 +145,7 @@ func (environment sandboxCacheEnvironment) names() []string { "AS", "CARGO_HOME", "RUSTUP_HOME", + "UV_PROJECT_ENVIRONMENT", evaluators.RealGitEnv, } } @@ -200,26 +205,65 @@ func sandboxCacheEnv( cargoHome, rustupHome := rustHomes() + uvProjectEnvironment, err := sandboxUVProjectEnvironment(root, request) + if err != nil { + return sandboxCacheEnvironment{}, err + } + return sandboxCacheEnvironment{ - TempDir: tempDir, - RuntimeDir: runtimeDir, - CleanupTemp: cleanupTemp, - GoCache: goCaches.Cache, - GoPath: goCaches.Path, - GoModCache: goCaches.ModCache, - GolangCILintDir: goCaches.GolangCILint, - GoRoot: managedGoRoot(ctx), - CGOEnabled: "1", - CC: cCompiler, - CompilerPath: managedCompilerPath(cCompiler, assembler), - Assembler: assembler, - RealGit: realGit, - PathPrefix: pathPrefix, - CargoHome: cargoHome, - RustupHome: rustupHome, + TempDir: tempDir, + RuntimeDir: runtimeDir, + CleanupTemp: cleanupTemp, + GoCache: goCaches.Cache, + GoPath: goCaches.Path, + GoModCache: goCaches.ModCache, + GolangCILintDir: goCaches.GolangCILint, + GoRoot: managedGoRoot(ctx), + CGOEnabled: "1", + CC: cCompiler, + CompilerPath: managedCompilerPath(cCompiler, assembler), + Assembler: assembler, + RealGit: realGit, + PathPrefix: pathPrefix, + CargoHome: cargoHome, + RustupHome: rustupHome, + UVProjectEnvironment: uvProjectEnvironment, }, nil } +func sandboxUVProjectEnvironment(root string, request captureRequest) (string, error) { + project := strings.TrimSpace(request.UVProject) + if project == "" { + return "", nil + } + + canonical, err := filepath.Abs(project) + if err != nil { + return "", fmt.Errorf("resolve uv project path %s: %w", project, err) + } + + canonical, err = filepath.EvalSymlinks(canonical) + if err != nil { + return "", fmt.Errorf("resolve uv project symlinks %s: %w", project, err) + } + + digest := sha256.Sum256([]byte(canonical)) + environment := filepath.Join( + root, + ".coding-ethos", + "cache", + "uv-project-env", + hex.EncodeToString(digest[:]), + ) + + err = makeSandboxDir(environment, "UV project environment") + if err != nil { + return "", err + } + + return environment, nil +} + func prepareSandboxGoCachePaths(root string) (sandboxGoCachePaths, error) { paths := sandboxGoCachePaths{ Cache: filepath.Join(root, sandbox.SandboxGoCachePath), diff --git a/go/internal/managedcapture/sandbox_cache_env_test.go b/go/internal/managedcapture/sandbox_cache_env_test.go index 5704315d..dd510144 100644 --- a/go/internal/managedcapture/sandbox_cache_env_test.go +++ b/go/internal/managedcapture/sandbox_cache_env_test.go @@ -5,11 +5,16 @@ package managedcapture import ( "context" + "crypto/sha256" + "encoding/hex" "os" "path/filepath" + "strings" "testing" "blackcat.ca/coding-ethos/go/internal/sandbox" + "blackcat.ca/coding-ethos/go/lintcapture" + "blackcat.ca/coding-ethos/go/toolcatalog" ) func TestSandboxCacheEnvCreatesConsumerGoPath(t *testing.T) { @@ -43,3 +48,88 @@ func TestSandboxCacheEnvCreatesConsumerGoPath(t *testing.T) { } } } + +func TestSandboxCacheEnvIsolatesUVProjectUnderConsumerCache(t *testing.T) { + consumerRoot := t.TempDir() + installedEthosRoot := t.TempDir() + hookProject := filepath.Join(installedEthosRoot, "pre-commit", "hooks") + if err := os.MkdirAll(hookProject, 0o700); err != nil { + t.Fatalf("create installed hook project: %v", err) + } + + uvPath := filepath.Join(t.TempDir(), "managed-uv") + if err := os.WriteFile(uvPath, []byte("fixture"), 0o700); err != nil { + t.Fatalf("write managed uv fixture: %v", err) + } + t.Setenv("UV", uvPath) + + tool, found := toolcatalog.HookOwnedTool("pyupgrade") + if !found { + t.Fatal("missing pyupgrade tool") + } + + request, exitCode, err := managedCaptureRequest( + tool, + lintcapture.RuntimeConfig{}, + Options{ + Tool: "pyupgrade", + EthosRoot: installedEthosRoot, + ConsumerRoot: consumerRoot, + InvocationCwd: consumerRoot, + Args: []string{"--version"}, + }, + ) + if err != nil { + t.Fatalf("managedCaptureRequest exit=%d: %v", exitCode, err) + } + if request.UVProject != hookProject { + t.Fatalf("UV project = %q, want %q", request.UVProject, hookProject) + } + + environment, err := sandboxCacheEnv(context.Background(), request) + if err != nil { + t.Fatalf("sandboxCacheEnv: %v", err) + } + + digest := sha256.Sum256([]byte(hookProject)) + want := filepath.Join( + consumerRoot, + ".coding-ethos", + "cache", + "uv-project-env", + hex.EncodeToString(digest[:]), + ) + if environment.UVProjectEnvironment != want { + t.Fatalf( + "UV_PROJECT_ENVIRONMENT = %q, want %q", + environment.UVProjectEnvironment, + want, + ) + } + if strings.HasPrefix( + environment.UVProjectEnvironment, + hookProject+string(os.PathSeparator), + ) { + t.Fatalf("UV project environment is inside installed hook project: %q", want) + } + if info, statErr := os.Stat(want); statErr != nil || !info.IsDir() { + t.Fatalf("UV project environment is not usable: info=%v error=%v", info, statErr) + } + + hostValue := filepath.Join(installedEthosRoot, "host-uv-project") + processEnv := capturedProcessEnv( + []string{"UV_PROJECT_ENVIRONMENT=" + hostValue}, + environment, + "pyupgrade", + ) + for _, item := range processEnv { + if item == "UV_PROJECT_ENVIRONMENT="+hostValue { + t.Fatalf("captured process retained host UV project environment") + } + if item == "UV_PROJECT_ENVIRONMENT="+want { + return + } + } + + t.Fatalf("captured process omitted UV project environment") +} diff --git a/go/internal/mcp/server.go b/go/internal/mcp/server.go index f8c06dc7..7aa189e9 100644 --- a/go/internal/mcp/server.go +++ b/go/internal/mcp/server.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "os" + "path/filepath" "slices" "strings" "time" @@ -41,6 +42,9 @@ var ( errManagedLintRuntimeUnavailable = apperror.StaticError( "managed lint runtime is not configured", ) + errManagedLintExecutionRootUnavailable = apperror.StaticError( + "managed lint requested cwd has no owning Git repository", + ) errCodeIntelRootUnavailable = apperror.StaticError( "code-intel root is not configured", ) @@ -478,12 +482,19 @@ func (server Server) checkManagedLint(input lintCheckInput) (any, error) { var stdout bytes.Buffer + executionRoot, invocationCwd, contextErr := server.managedLintExecutionContext( + input.Cwd, + ) + if contextErr != nil { + return nil, contextErr + } + exitCode := managedcapture.Run(managedcapture.Options{ PolicyContext: managedLintPolicyContext(server.bundle), Tool: tool, EthosRoot: server.runtime.EthosRoot, - ConsumerRoot: server.runtime.ConsumerRoot, - InvocationCwd: firstNonEmpty(input.Cwd, server.runtime.InvocationCwd), + ConsumerRoot: executionRoot, + InvocationCwd: invocationCwd, OutputFormat: hookoutput.FormatJSON, Output: &stdout, Args: managedLintToolArgs(input), @@ -503,7 +514,192 @@ func (server Server) checkManagedLint(input lintCheckInput) (any, error) { ) } - return managedLintOutput(tool, exitCode, result), nil + return managedLintOutput( + tool, + exitCode, + result, + executionRoot, + invocationCwd, + ), nil +} + +func (server Server) managedLintExecutionContext( + requestedCwd string, +) (string, string, error) { + requestedCwd = strings.TrimSpace(requestedCwd) + executionRoot := managedLintAbsolutePath(server.runtime.ConsumerRoot) + + if requestedCwd == "" { + invocationCwd := managedLintAbsolutePath(firstNonEmpty( + server.runtime.InvocationCwd, + server.runtime.ConsumerRoot, + )) + + return executionRoot, invocationCwd, nil + } + + invocationCwd, err := managedLintCanonicalDirectory(requestedCwd) + if err != nil { + return "", "", err + } + + configuredRoot, err := managedLintCanonicalDirectory( + server.runtime.ConsumerRoot, + ) + if err != nil { + return "", "", err + } + + if managedLintPathWithin(configuredRoot, invocationCwd) { + return configuredRoot, invocationCwd, nil + } + + repositoryRoot, found := managedLintRepositoryRoot(invocationCwd) + if !found { + return "", "", fmt.Errorf( + "resolve managed lint execution root for %q: %w", + invocationCwd, + errManagedLintExecutionRootUnavailable, + ) + } + + return repositoryRoot, invocationCwd, nil +} + +func managedLintAbsolutePath(path string) string { + absolute, err := filepath.Abs(strings.TrimSpace(path)) + if err != nil { + return filepath.Clean(path) + } + + return filepath.Clean(absolute) +} + +func managedLintCanonicalDirectory(path string) (string, error) { + absolute := managedLintAbsolutePath(path) + + canonical, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", fmt.Errorf("resolve managed lint cwd %q: %w", path, err) + } + + info, err := os.Stat(canonical) + if err != nil { + return "", fmt.Errorf("inspect managed lint cwd %q: %w", path, err) + } + + if !info.IsDir() { + return "", fmt.Errorf( + "resolve managed lint cwd %q: %w", + path, + errManagedLintExecutionRootUnavailable, + ) + } + + return filepath.Clean(canonical), nil +} + +func managedLintPathWithin(root, path string) bool { + relative, err := filepath.Rel(root, path) + if err != nil { + return false + } + + return relative == "." || + (relative != ".." && + !strings.HasPrefix(relative, ".."+string(filepath.Separator))) +} + +func managedLintRepositoryRoot(cwd string) (string, bool) { + directory := managedLintAbsolutePath(cwd) + + for { + markerPresent, markerValid := managedLintGitMarker(directory) + if markerValid { + return directory, true + } + + if markerPresent { + return "", false + } + + parent := filepath.Dir(directory) + if parent == directory { + return "", false + } + + directory = parent + } +} + +func managedLintGitMarker(repositoryRoot string) (bool, bool) { + marker := filepath.Join(repositoryRoot, ".git") + + info, err := os.Lstat(marker) + if errors.Is(err, os.ErrNotExist) { + return false, false + } + + if err != nil { + return true, false + } + + if info.IsDir() { + return true, true + } + + if !info.Mode().IsRegular() { + return true, false + } + + return true, managedLintGitFileMarkerValid(repositoryRoot, marker) +} + +func managedLintGitFileMarkerValid(repositoryRoot, marker string) bool { + contents, err := os.ReadFile(marker) + if err != nil { + return false + } + + gitDirectory, found := strings.CutPrefix( + strings.TrimSpace(string(contents)), + "gitdir:", + ) + + gitDirectory = strings.TrimSpace(gitDirectory) + if !found || gitDirectory == "" { + return false + } + + if strings.ContainsAny(gitDirectory, "\r\n") { + return false + } + + if !filepath.IsAbs(gitDirectory) { + gitDirectory = filepath.Join(repositoryRoot, gitDirectory) + } + + return managedLintGitDirectoryExists(gitDirectory) +} + +func managedLintGitDirectoryExists(gitDirectory string) bool { + gitDirectory = filepath.Clean(gitDirectory) + parent, basename := filepath.Dir(gitDirectory), filepath.Base(gitDirectory) + + if basename == "." || basename == string(filepath.Separator) { + return false + } + + root, err := os.OpenRoot(parent) + if err != nil { + return false + } + + defer func() { _ = root.Close() }() + + gitInfo, err := root.Stat(basename) + + return err == nil && gitInfo.IsDir() } func managedLintToolArgs(input lintCheckInput) []string { @@ -535,18 +731,22 @@ func managedLintOutput( tool string, exitCode int, result lint.Result, + executionRoot string, + invocationCwd string, ) map[string]any { return map[string]any{ - "engine": "managed_lint_capture", - "tool": tool, - "exit_code": exitCode, - "status": result.Status, - "blocked": result.Blocked(), - "files": result.Files, - "findings": result.Findings, - "diagnostics": result.Diagnostics, - "skill_hints": result.SkillHints, - "capture": result.Capture, + "engine": "managed_lint_capture", + "tool": tool, + "exit_code": exitCode, + "status": result.Status, + "blocked": result.Blocked(), + "files": result.Files, + "findings": result.Findings, + "diagnostics": result.Diagnostics, + "skill_hints": result.SkillHints, + "capture": result.Capture, + "execution_root": executionRoot, + "cwd": invocationCwd, } } diff --git a/go/internal/mcp/server_test.go b/go/internal/mcp/server_test.go index 9cc8885b..2b016f4a 100644 --- a/go/internal/mcp/server_test.go +++ b/go/internal/mcp/server_test.go @@ -815,6 +815,286 @@ func TestServerLintCheckRunsManagedToolCapture(t *testing.T) { } } +func TestServerManagedLintUsesRequestedRepositoryDirectory(t *testing.T) { + t.Parallel() + + assertServerManagedLintUsesRequestedRoot(t, "directory") +} + +func TestServerManagedLintUsesRequestedLinkedWorktree(t *testing.T) { + t.Parallel() + + assertServerManagedLintUsesRequestedRoot(t, "worktree-file") +} + +func TestServerManagedLintUsesRequestedRelativeLinkedWorktree(t *testing.T) { + t.Parallel() + + assertServerManagedLintUsesRequestedRoot(t, "relative-worktree-file") +} + +func TestServerManagedLintRejectsUnresolvedRequestedCwd(t *testing.T) { + t.Parallel() + + serverRoot := t.TempDir() + missingCwd := filepath.Join(t.TempDir(), "missing") + + output := runServerWithRuntime(t, compactJSON(t, fmt.Sprintf(`{ + "jsonrpc":"2.0", + "id":82, + "method":"tools/call", + "params":{ + "name":"managed_lint", + "arguments":{"tool":"ruff","cwd":%q} + } + }`, missingCwd)), mcp.Runtime{ + BundlePath: filepath.Join(serverRoot, "policy-bundle.json"), + EthosRoot: serverRoot, + ConsumerRoot: serverRoot, + InvocationCwd: serverRoot, + }) + response := decodeResponse(t, output) + + if response["error"] == nil || + !strings.Contains(output, "resolve managed lint cwd") { + t.Fatalf("unresolved requested cwd did not fail closed:\n%s", output) + } +} + +func TestServerManagedLintRejectsMalformedLinkedWorktreeMarker(t *testing.T) { + t.Parallel() + + assertServerManagedLintRejectsGitMarker(t, "not-a-gitdir\n") +} + +func TestServerManagedLintRejectsMissingLinkedWorktreeTarget(t *testing.T) { + t.Parallel() + + missingGitDirectory := filepath.Join(t.TempDir(), "missing-gitdir") + assertServerManagedLintRejectsGitMarker( + t, + "gitdir: "+missingGitDirectory+"\n", + ) +} + +func assertServerManagedLintRejectsGitMarker(t *testing.T, contents string) { + t.Helper() + + serverRoot := t.TempDir() + requestedRoot := filepath.Join(t.TempDir(), "requested-worktree") + requestedCwd := filepath.Join(requestedRoot, "nested") + + err := os.MkdirAll(requestedCwd, 0o700) + if err != nil { + t.Fatalf("create requested worktree: %v", err) + } + + err = os.WriteFile( + filepath.Join(requestedRoot, ".git"), + []byte(contents), + 0o600, + ) + if err != nil { + t.Fatalf("write linked worktree git file: %v", err) + } + + output := runServerWithRuntime(t, compactJSON(t, fmt.Sprintf(`{ + "jsonrpc":"2.0", + "id":83, + "method":"tools/call", + "params":{ + "name":"managed_lint", + "arguments":{"tool":"ruff","cwd":%q} + } + }`, requestedCwd)), mcp.Runtime{ + BundlePath: filepath.Join(serverRoot, "policy-bundle.json"), + EthosRoot: serverRoot, + ConsumerRoot: serverRoot, + InvocationCwd: serverRoot, + }) + response := decodeResponse(t, output) + + if response["error"] == nil || + !strings.Contains(output, "no owning Git repository") { + t.Fatalf("invalid linked worktree marker did not fail closed:\n%s", output) + } +} + +func assertServerManagedLintUsesRequestedRoot(t *testing.T, markerKind string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("managed lint fixture uses POSIX sh") + } + + serverRoot := t.TempDir() + writeManagedLintRuntimeFixture(t, serverRoot) + + requestedRoot := filepath.Join(t.TempDir(), "requested-worktree") + requestedCwd := filepath.Join(requestedRoot, "nested") + + err := os.MkdirAll(requestedCwd, 0o700) + if err != nil { + t.Fatalf("create requested worktree: %v", err) + } + + writeManagedLintGitMarker(t, requestedRoot, markerKind) + + _, err = toolconfigs.Sync(serverRoot, requestedRoot, "") + if err != nil { + t.Fatalf("sync requested worktree tool configs: %v", err) + } + + err = os.WriteFile( + filepath.Join(requestedRoot, "requested-worktree.marker"), + []byte("requested\n"), + 0o600, + ) + if err != nil { + t.Fatalf("write requested worktree marker: %v", err) + } + + pwdPath := filepath.Join( + requestedRoot, + ".coding-ethos", + "cache", + "managed-lint-pwd", + ) + err = os.MkdirAll(filepath.Dir(pwdPath), 0o700) + if err != nil { + t.Fatalf("create managed lint PWD evidence directory: %v", err) + } + + writeExecutable( + t, + filepath.Join(serverRoot, ".venv", "bin", "ruff"), + managedLintRequestedRootRuffFixture(), + ) + + output := runServerWithRuntime(t, compactJSON(t, fmt.Sprintf(`{ + "jsonrpc":"2.0", + "id":81, + "method":"tools/call", + "params":{ + "name":"managed_lint", + "arguments":{"tool":"ruff","cwd":%q} + } + }`, requestedCwd)), mcp.Runtime{ + BundlePath: filepath.Join(serverRoot, "policy-bundle.json"), + EthosRoot: serverRoot, + ConsumerRoot: serverRoot, + InvocationCwd: serverRoot, + }) + response := decodeResponse(t, output) + content := structuredContent(t, response) + + if content["execution_root"] != requestedRoot || content["cwd"] != requestedCwd { + t.Fatalf("managed lint execution evidence = %#v", content) + } + + if strings.Contains(output, "runtime.sandbox_denial") && + strings.Contains(output, "coding-ethos-sandbox") && + strings.Contains(strings.ToLower(output), "permission denied") { + t.Skip("nested managed-lint fixture execution is denied by this sandbox") + } + + if strings.Contains(output, "runtime.sandbox_denial") { + t.Fatalf("managed lint sandbox unexpectedly denied:\n%s", output) + } + + actualCwd, err := os.ReadFile(pwdPath) + if err != nil { + t.Fatalf("read managed lint PWD evidence: %v", err) + } + if strings.TrimSpace(string(actualCwd)) != requestedRoot { + t.Fatalf( + "managed lint PWD = %q, want %q", + strings.TrimSpace(string(actualCwd)), + requestedRoot, + ) + } + + if !strings.Contains(output, `"file":"src/requested-worktree.py"`) || + strings.Contains(output, `"file":"src/server-root.py"`) { + t.Fatalf("managed lint did not execute from requested worktree:\n%s", output) + } +} + +func writeManagedLintGitMarker(t *testing.T, root, markerKind string) { + t.Helper() + + marker := filepath.Join(root, ".git") + + switch markerKind { + case "directory": + err := os.MkdirAll(marker, 0o700) + if err != nil { + t.Fatalf("create repository git directory: %v", err) + } + case "worktree-file": + gitDirectory := filepath.Join(t.TempDir(), "gitdir") + err := os.MkdirAll(gitDirectory, 0o700) + if err != nil { + t.Fatalf("create linked worktree git directory: %v", err) + } + + err = os.WriteFile( + marker, + []byte("gitdir: "+gitDirectory+"\n"), + 0o600, + ) + if err != nil { + t.Fatalf("write linked worktree git file: %v", err) + } + case "relative-worktree-file": + gitDirectory := filepath.Join(root, ".git-worktree") + err := os.MkdirAll(gitDirectory, 0o700) + if err != nil { + t.Fatalf("create relative linked worktree git directory: %v", err) + } + + err = os.WriteFile( + marker, + []byte("gitdir: .git-worktree\n"), + 0o600, + ) + if err != nil { + t.Fatalf("write relative linked worktree git file: %v", err) + } + default: + t.Fatalf("unsupported managed lint git marker kind %q", markerKind) + } +} + +func managedLintRequestedRootRuffFixture() string { + return `#!/usr/bin/env sh +pwd > .coding-ethos/cache/managed-lint-pwd +if [ -f requested-worktree.marker ]; then + cat <<'JSON' +[ + { + "filename": "src/requested-worktree.py", + "code": "F401", + "message": "requested worktree", + "location": {"row": 1, "column": 1} + } +] +JSON +else + cat <<'JSON' +[ + { + "filename": "src/server-root.py", + "code": "F401", + "message": "server root", + "location": {"row": 1, "column": 1} + } +] +JSON +fi +exit 1 +` +} + func writeManagedLintRuntimeFixture(t *testing.T, root string) { t.Helper() diff --git a/go/internal/policy/bundle.go b/go/internal/policy/bundle.go index 29ac0c53..1fcd5a36 100644 --- a/go/internal/policy/bundle.go +++ b/go/internal/policy/bundle.go @@ -172,7 +172,7 @@ func ExampleBundle() Bundle { Sources: exampleSources(), Advice: exampleAdvice(), Principles: principles, - Policies: examplePolicies(), + Policies: examplePolicies(principles), Skills: exampleSkills(), Dispatch: exampleDispatch(), EvidenceMaps: defaultEvidenceMaps(principles), @@ -264,7 +264,7 @@ func examplePrinciples() map[string]Principle { } } -func examplePolicies() map[string]Policy { +func examplePolicies(principles map[string]Principle) map[string]Policy { return map[string]Policy{ "python.conditional_imports": exampleConditionalImportPolicy(), "python.functional_idioms": exampleFunctionalIdiomPolicy(), @@ -277,7 +277,7 @@ func examplePolicies() map[string]Policy { "filesystem.protected_path": exampleProtectedPathPolicy(), "proxy.search_replace_edit": ProxySearchReplaceEditPolicy(), "shell.malformed_command": exampleShellMalformedCommandPolicy(), - "shell.required_gate_exit_status": exampleShellRequiredGateExitStatusPolicy(), + "shell.required_gate_exit_status": requiredGateExitStatusRoutePolicy(principles), "shell.forbidden_strings": exampleShellForbiddenStringsPolicy(), } } @@ -1083,32 +1083,6 @@ func exampleShellMalformedCommandPolicy() Policy { } } -func exampleShellRequiredGateExitStatusPolicy() Policy { - return Policy{ - ID: "shell.required_gate_exit_status", - Category: "shell", - Source: SourceRef{ - File: "config.yaml", - Path: "shell.required_gate_exit_status", - }, - PrincipleIDs: []string{ - "validation-at-the-gate", - "evidence-based-engineering-and-decision-quality", - }, - DefaultSeverity: "block", - SupportedModes: []string{"block", "record"}, - Message: "Required repository gates must return their own " + - "exact terminal status.", - Suggestion: "Run the gate directly, enable pipefail for pipelines, or capture " + - "and return the gate's exact status.", - DefenseLayers: hookRouteDefenseLayers(), - AppliesTo: AppliesTo{Tools: []string{"Bash"}}, - Evaluators: []Evaluator{ - {Kind: "external", Name: "shell.required_gate_exit_status"}, - }, - } -} - func exampleLinterDispatch() map[string][]string { return map[string][]string{ "files": {"python.conditional_imports", "python.functional_idioms"}, diff --git a/go/internal/policy/compiler_policies_internal_test.go b/go/internal/policy/compiler_policies_internal_test.go index 68e00bda..3a56cb56 100644 --- a/go/internal/policy/compiler_policies_internal_test.go +++ b/go/internal/policy/compiler_policies_internal_test.go @@ -3,7 +3,10 @@ package policy -import "testing" +import ( + "reflect" + "testing" +) func TestSPDXLicenseTextURLUsesLicenseListDataTextSource(t *testing.T) { t.Parallel() @@ -47,3 +50,32 @@ func TestValidateSPDXLicenseIDAllowsCommonIdentifierCharacters(t *testing.T) { } } } + +func TestExampleBundleUsesOneCanonicalRequiredGateExitStatusPolicy(t *testing.T) { + t.Parallel() + + bundle := ExampleBundle() + want := requiredGateExitStatusRoutePolicy(bundle.Principles) + got, ok := bundle.Policies[want.ID] + if !ok { + t.Fatalf("example bundle missing %q", want.ID) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("example policy = %#v, want canonical policy %#v", got, want) + } + + count := 0 + for key, candidate := range bundle.Policies { + if candidate.ID != want.ID { + continue + } + + count++ + if key != want.ID { + t.Errorf("canonical policy stored under key %q, want %q", key, want.ID) + } + } + if count != 1 { + t.Fatalf("example bundle contains %d policies with ID %q, want 1", count, want.ID) + } +} diff --git a/go/internal/policygitcli/main.go b/go/internal/policygitcli/main.go index c04e10ad..3009aea8 100644 --- a/go/internal/policygitcli/main.go +++ b/go/internal/policygitcli/main.go @@ -239,6 +239,17 @@ func wrapperBoolValue(name, value string, hasValue bool) (bool, error) { } func gitGlobalOptionStartsArgv(argument string) bool { + switch argument { + case "--no-optional-locks", "--no-lazy-fetch", "--no-advice": + return true + } + + for _, prefix := range []string{"--attr-source=", "--list-cmds="} { + if strings.HasPrefix(argument, prefix) && len(argument) > len(prefix) { + return true + } + } + if strings.HasPrefix(argument, "-c=") || strings.HasPrefix(argument, "-C=") { return true } diff --git a/go/internal/policygitcli/main_internal_test.go b/go/internal/policygitcli/main_internal_test.go index 794cb6e4..b0f566d5 100644 --- a/go/internal/policygitcli/main_internal_test.go +++ b/go/internal/policygitcli/main_internal_test.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "slices" "strings" "testing" @@ -101,6 +102,55 @@ func TestGitGlobalOptionStartsArgvRecognizesShortMetaOptions(t *testing.T) { } } +func TestParsePolicyGitArgsPreservesSupportedGitGlobalOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + argv []string + }{ + {name: "no optional locks", argv: []string{"--no-optional-locks", "status"}}, + {name: "no lazy fetch", argv: []string{"--no-lazy-fetch", "status"}}, + {name: "no advice", argv: []string{"--no-advice", "status"}}, + {name: "attribute source", argv: []string{"--attr-source=HEAD", "status"}}, + {name: "command groups", argv: []string{"--list-cmds=main,others"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + args := append([]string{"--bundle=/policy/bundle.json"}, test.argv...) + parsed, err := parsePolicyGitArgs(args) + if err != nil { + t.Fatalf("parsePolicyGitArgs: %v", err) + } + + if !slices.Equal(parsed.gitArgv, test.argv) { + t.Fatalf("git argv = %#v, want %#v", parsed.gitArgv, test.argv) + } + }) + } +} + +func TestGitGlobalOptionStartsArgvRejectsInvalidNewOptionForms(t *testing.T) { + t.Parallel() + + for _, option := range []string{ + "--no-optional-locks=true", + "--no-lazy-fetch=false", + "--no-advice=true", + "--attr-source", + "--attr-source=", + "--list-cmds", + "--list-cmds=", + } { + if gitGlobalOptionStartsArgv(option) { + t.Fatalf("gitGlobalOptionStartsArgv(%q) = true, want false", option) + } + } +} + func TestParsePolicyGitArgsHonorsExplicitBoundary(t *testing.T) { t.Parallel() @@ -137,6 +187,43 @@ func TestParsePolicyGitArgsRejectsUnknownWrapperOption(t *testing.T) { } } +func TestParsePolicyGitArgsRejectsInvalidWrapperValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want error + }{ + { + name: "required value", + args: []string{"--bundle"}, + want: errWrapperValueRequired, + }, + { + name: "empty value", + args: []string{"--bundle="}, + want: errWrapperOptionEmpty, + }, + { + name: "invalid boolean", + args: []string{"--json=maybe"}, + want: errWrapperBoolValue, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + _, err := parsePolicyGitArgs(test.args) + if !errors.Is(err, test.want) { + t.Fatalf("parsePolicyGitArgs(%#v) error = %v, want %v", test.args, err, test.want) + } + }) + } +} + func TestGitOptionsForNonStdinCommand(t *testing.T) { t.Parallel() diff --git a/go/internal/sandbox/cgroup_linux.go b/go/internal/sandbox/cgroup_linux.go index 6e596cb8..97e51248 100644 --- a/go/internal/sandbox/cgroup_linux.go +++ b/go/internal/sandbox/cgroup_linux.go @@ -145,6 +145,9 @@ func SysProcAttr(cgroup *Cgroup, evidence Evidence) *syscall.SysProcAttr { } attributes.Setpgid = true + if evidence.Reason == activeAgentShellReuseReason { + return attributes + } needsFilesystemNamespace := !evidence.RequiresProcesses || len(evidence.ReadOnlyPaths) > 0 diff --git a/go/internal/sandbox/cgroup_linux_test.go b/go/internal/sandbox/cgroup_linux_test.go index a1ecc88e..e6a828c5 100644 --- a/go/internal/sandbox/cgroup_linux_test.go +++ b/go/internal/sandbox/cgroup_linux_test.go @@ -90,6 +90,34 @@ func TestProcessEnabledSandboxWithReadOnlyPinsRetainsFilesystemNamespaces( } } +func TestReusedActiveAgentShellDoesNotCreateNestedNamespaces(t *testing.T) { + t.Parallel() + + attributes := SysProcAttr(nil, Evidence{ + Enabled: true, + NamespaceEnforced: true, + Reason: activeAgentShellReuseReason, + }) + if attributes == nil { + t.Fatal("SysProcAttr() = nil") + } + if !attributes.Setpgid { + t.Fatal("reused active sandbox lost process-group isolation") + } + if attributes.Cloneflags != 0 { + t.Fatalf( + "reused active sandbox created nested namespaces: %#x", + attributes.Cloneflags, + ) + } + if len(attributes.UidMappings) != 0 || len(attributes.GidMappings) != 0 { + t.Fatalf("reused active sandbox changed identity: %#v", attributes) + } + if len(attributes.AmbientCaps) != 0 { + t.Fatalf("reused active sandbox gained capabilities: %#v", attributes.AmbientCaps) + } +} + func TestProcessEnabledSandboxWithoutReadOnlyPinsPreservesHostNamespaces(t *testing.T) { t.Parallel() diff --git a/go/internal/sandbox/sandbox.go b/go/internal/sandbox/sandbox.go index afa68fb4..3298f0ac 100644 --- a/go/internal/sandbox/sandbox.go +++ b/go/internal/sandbox/sandbox.go @@ -12,12 +12,14 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "time" "blackcat.ca/coding-ethos/go/internal/apperror" "blackcat.ca/coding-ethos/go/internal/debuglog" "blackcat.ca/coding-ethos/go/internal/processstatus" "blackcat.ca/coding-ethos/go/internal/safeexec" + "blackcat.ca/coding-ethos/go/internal/sandboxexec" ) const ( @@ -25,18 +27,20 @@ const ( BackendNative = "native" - cgroupLineParts = 3 - toolFallbackName = "tool" - nativeSandboxBinary = "coding-ethos-sandbox" - nativeProbeTimeout = 10 - nativeProbeDirMode = 0o700 - nativeProbeFileMode = 0o700 - nativeProbeWriteMode = 0o600 - SandboxTempWritePath = ".coding-ethos/cache/sandbox-tmp" - SandboxGoCachePath = ".coding-ethos/cache/go-build" - SandboxGoPath = ".coding-ethos/cache/go-path" - SandboxGoModCachePath = SandboxGoPath + "/pkg/mod" - SandboxGolangCIPath = ".coding-ethos/cache/golangci-lint" + cgroupLineParts = 3 + toolFallbackName = "tool" + nativeSandboxBinary = "coding-ethos-sandbox" + nativeProbeTimeout = 10 + nativeProbeDirMode = 0o700 + nativeProbeFileMode = 0o700 + nativeProbeWriteMode = 0o600 + nativeProbeSandboxProfile = "native-probe" + activeAgentShellReuseReason = "reusing active agent-shell sandbox" + SandboxTempWritePath = ".coding-ethos/cache/sandbox-tmp" + SandboxGoCachePath = ".coding-ethos/cache/go-build" + SandboxGoPath = ".coding-ethos/cache/go-path" + SandboxGoModCachePath = SandboxGoPath + "/pkg/mod" + SandboxGolangCIPath = ".coding-ethos/cache/golangci-lint" ) var ( @@ -206,17 +210,15 @@ func BuildPlan(request Request) (Plan, error) { evidence.Enabled = true evidence.NamespaceEnforced = !request.Capabilities.RequiresProcesses - if activeAgentShellSandboxCovers(request) { - evidence.BackendPath = strings.TrimSpace(os.Getenv("CODING_ETHOS_REAL_GIT")) - evidence.Reason = "reusing active agent-shell sandbox" - - return unsandboxedPlan(request, evidence), nil - } - if strings.TrimSpace(request.Capabilities.SeccompProfilePath) != "" { return deniedSeccompPlan(evidence, errNativeSeccompUnsupported) } + if verifiedActiveAgentShellSandboxCovers(request) && + activeAgentShellSandboxReusable(request) { + evidence.Reason = activeAgentShellReuseReason + } + wrapper, wrapperErr := nativeWrapperPath(request) if wrapperErr != nil { return deniedSandboxPlan(evidence, wrapperErr) @@ -236,7 +238,15 @@ func sandboxRequired(request Request) bool { strings.TrimSpace(request.Capabilities.SandboxProfile) != "" } -func activeAgentShellSandboxCovers(request Request) bool { +func activeAgentShellSandboxReusable(request Request) bool { + return request.Capabilities.SandboxProfile != nativeProbeSandboxProfile && + !gitBindingRequested( + request.Capabilities, + normalizedGitTargetPaths(request.Capabilities.GitTargetPaths), + ) +} + +func verifiedActiveAgentShellSandboxCovers(request Request) bool { if os.Getenv("CODING_ETHOS_AGENT_SHELL_SANDBOX") != "1" || os.Getenv("CODING_ETHOS_SANDBOX_ACTIVE") != "1" { return false @@ -249,7 +259,8 @@ func activeAgentShellSandboxCovers(request Request) bool { return false } - if !isWithinPath(request.RepoRoot, root) || + repoRoot := filepath.Clean(strings.TrimSpace(request.RepoRoot)) + if repoRoot == "." || repoRoot != root || !isWithinPath(request.Cwd, root) || !isWithinPath(realGit, root) { return false @@ -348,6 +359,11 @@ func ValidateNativeRuntimeWithHelper(wrapperPath string) (Evidence, error) { ) } + activeEvidence, active, activeErr := validateActiveAgentShellNativeRuntime(wrapper) + if active { + return activeEvidence, activeErr + } + repoRoot, err := os.MkdirTemp("", "coding-ethos-sandbox-probe-") if err != nil { return Evidence{}, fmt.Errorf("create native sandbox probe repo: %w", err) @@ -355,6 +371,11 @@ func ValidateNativeRuntimeWithHelper(wrapperPath string) (Evidence, error) { defer func() { _ = os.RemoveAll(repoRoot) }() + err = prepareNativeProbeRepo(repoRoot) + if err != nil { + return Evidence{}, err + } + plan, err := BuildPlan(Request{ Tool: "sandbox-probe", Executable: "/bin/sh", @@ -363,7 +384,7 @@ func ValidateNativeRuntimeWithHelper(wrapperPath string) (Evidence, error) { RepoRoot: repoRoot, Args: nativeProbeArgs(), Capabilities: Capabilities{ - SandboxProfile: "native-probe", + SandboxProfile: nativeProbeSandboxProfile, WritePaths: []string{".coding-ethos/cache"}, }, }) @@ -373,7 +394,7 @@ func ValidateNativeRuntimeWithHelper(wrapperPath string) (Evidence, error) { evidence, err := runNativeRuntimeProbe(repoRoot, plan) if err != nil { - return evidence, err + return evidence, fmt.Errorf("run native filesystem probe: %w", err) } evidence, err = validateNativeProbeSideEffects(repoRoot, evidence) @@ -381,7 +402,153 @@ func ValidateNativeRuntimeWithHelper(wrapperPath string) (Evidence, error) { return evidence, err } - return validateNativeGitBindProbe(repoRoot, wrapper, evidence) + evidence, err = validateNativeGitBindProbe(repoRoot, wrapper, evidence) + if err != nil { + return evidence, fmt.Errorf("run native git bind probe: %w", err) + } + + return evidence, nil +} + +func validateActiveAgentShellNativeRuntime(wrapper string) (Evidence, bool, error) { + if !activeAgentShellNativeRuntime() { + return Evidence{}, false, nil + } + + evidence := nativeRuntimeEvidence() + evidence.BackendPath = wrapper + evidence.Enabled = true + evidence.NamespaceEnforced = true + + root, validRoot := activeAgentShellSandboxRoot() + if !validRoot { + return deniedActiveAgentShellEvidence( + evidence, + "active agent-shell sandbox evidence is incomplete", + ) + } + + protectedRoot, reason := activeAgentShellProtectedRoot(root) + if reason != "" { + return deniedActiveAgentShellEvidence( + evidence, + reason, + ) + } + + reason = activeAgentShellCacheProbe(protectedRoot) + if reason != "" { + return deniedActiveAgentShellEvidence( + evidence, + reason, + ) + } + + reason = activeAgentShellProtectedRootProbe(protectedRoot) + if reason != "" { + return deniedActiveAgentShellEvidence( + evidence, + reason, + ) + } + + evidence.Reason = "validated active agent-shell native sandbox" + + return evidence, true, nil +} + +func activeAgentShellNativeRuntime() bool { + return os.Getenv("CODING_ETHOS_AGENT_SHELL_SANDBOX") == "1" && + os.Getenv("CODING_ETHOS_SANDBOX_ACTIVE") == "1" +} + +func activeAgentShellSandboxRoot() (string, bool) { + root := filepath.Clean(strings.TrimSpace(os.Getenv("CODING_ETHOS_SANDBOX_ROOT"))) + valid := root != "." && filepath.IsAbs(root) && + verifiedActiveAgentShellSandboxCovers(Request{RepoRoot: root, Cwd: root}) + + return root, valid +} + +func activeAgentShellProtectedRoot(root string) (string, string) { + mountInfo, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return "", fmt.Sprintf("read active agent-shell mount info: %v", err) + } + + protectedRoot := filepath.Join(root, ".coding-ethos") + if !sandboxexec.ReadOnlyMountInfoForPath(string(mountInfo), protectedRoot) { + return "", "active agent-shell .coding-ethos root is not an exact read-only mount" + } + + return protectedRoot, "" +} + +func activeAgentShellCacheProbe(protectedRoot string) string { + cacheRoot := filepath.Join(protectedRoot, "cache") + + probe, err := os.CreateTemp(cacheRoot, "native-runtime-active-probe-") + if err != nil { + return fmt.Sprintf("create active agent-shell cache probe: %v", err) + } + + probePath := probe.Name() + closeErr := probe.Close() + + removeErr := os.Remove(probePath) + if closeErr != nil || removeErr != nil { + return fmt.Sprintf( + "clean active agent-shell cache probe: close=%v remove=%v", + closeErr, + removeErr, + ) + } + + return "" +} + +func activeAgentShellProtectedRootProbe(protectedRoot string) string { + blockedPath := filepath.Join(protectedRoot, "native-runtime-active-probe-blocked") + + blocked, blockedErr := os.OpenFile( + blockedPath, + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + nativeProbeWriteMode, + ) + if blockedErr == nil { + _ = blocked.Close() + _ = os.Remove(blockedPath) + + return "active agent-shell protected root accepted an undeclared write" + } + + if !errors.Is(blockedErr, os.ErrPermission) && + !errors.Is(blockedErr, syscall.EROFS) { + return fmt.Sprintf("probe active agent-shell protected root: %v", blockedErr) + } + + return "" +} + +func deniedActiveAgentShellEvidence( + evidence Evidence, + reason string, +) (Evidence, bool, error) { + evidence.Denied = true + evidence.Reason = reason + + return evidence, true, fmt.Errorf("%w: %s", ErrBackendUnavailable, reason) +} + +func prepareNativeProbeRepo(repoRoot string) error { + writeRoot := filepath.Join(repoRoot, ".coding-ethos", "cache") + + err := os.MkdirAll(writeRoot, nativeProbeDirMode) + if err != nil { + return fmt.Errorf("create native sandbox probe write path: %w", err) + } + + return nil } func sandboxProbeExitCode(err error) int { @@ -494,11 +661,11 @@ func validateNativeGitBindProbe( WrapperPath: wrapper, Cwd: repoRoot, RepoRoot: repoRoot, - Args: []string{"status"}, + Args: []string{sandboxexec.NativeGitBindProbeMode}, Capabilities: Capabilities{ SandboxProfile: "native-git-bind-probe", - GitWrapperPath: probe.policyGit, - RealGitPath: probe.realGit, + GitWrapperPath: wrapper, + RealGitPath: wrapper, RealGitBindPath: probe.realGitBind, GitTargetPaths: []string{probe.targetGit}, WritePaths: []string{".coding-ethos/cache"}, @@ -518,8 +685,6 @@ func validateNativeGitBindProbe( } type nativeGitBindProbeFiles struct { - policyGit string - realGit string realGitBind string targetGit string } @@ -533,61 +698,17 @@ func nativeGitBindProbe(repoRoot string) (nativeGitBindProbeFiles, error) { } files := nativeGitBindProbeFiles{ - policyGit: filepath.Join(cacheRoot, "policy-git-probe"), - realGit: filepath.Join(cacheRoot, "real-git-probe"), realGitBind: filepath.Join(cacheRoot, "real-git-bind-probe"), targetGit: filepath.Join(cacheRoot, "target-git-probe"), } - policyScript := `#!/bin/sh -set -eu -readonly_bind=0 -while IFS= read -r line; do - set -- $line - if [ "${5:-}" = "$0" ]; then - case ",${6:-}," in - *,ro,*) readonly_bind=1 ;; - esac - fi -done < /proc/self/mountinfo -if [ "$readonly_bind" != 1 ]; then - printf not-read-only > .coding-ethos/cache/git-bind-not-read-only - exit 96 -fi -printf wrapper > .coding-ethos/cache/git-wrapper -exit 0 -` - targetScript := "#!/bin/sh\nprintf target > .coding-ethos/cache/git-target\nexit 97\n" - realGitScript := "#!/bin/sh\nexit 0\n" - - for path, content := range map[string]string{ - files.policyGit: policyScript, - files.realGit: realGitScript, - files.targetGit: targetScript, - } { - err = writeNativeProbeExecutable(path, []byte(content)) + for _, path := range []string{files.realGitBind, files.targetGit} { + err = writeNativeProbeExecutable(path, nil) if err != nil { return nativeGitBindProbeFiles{}, fmt.Errorf("write %s: %w", path, err) } } - realGitBind, err := os.OpenFile( - files.realGitBind, - os.O_CREATE|os.O_WRONLY|os.O_TRUNC, - nativeProbeFileMode, - ) - if err != nil { - return nativeGitBindProbeFiles{}, fmt.Errorf("create real git bind target: %w", err) - } - - closeErr := realGitBind.Close() - if closeErr != nil { - return nativeGitBindProbeFiles{}, fmt.Errorf( - "close real git bind target: %w", - closeErr, - ) - } - return files, nil } diff --git a/go/internal/sandbox/sandbox_internal_test.go b/go/internal/sandbox/sandbox_internal_test.go index 3d9f5f96..99a288c6 100644 --- a/go/internal/sandbox/sandbox_internal_test.go +++ b/go/internal/sandbox/sandbox_internal_test.go @@ -6,8 +6,11 @@ package sandbox import ( "errors" "os" + "os/exec" "path/filepath" "testing" + + "blackcat.ca/coding-ethos/go/internal/sandboxexec" ) func TestSafeCgroupNameSanitizesToolNames(t *testing.T) { @@ -33,7 +36,7 @@ func TestWriteNativeProbeExecutableCreatesPrivateExecutable(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "probe") - err := writeNativeProbeExecutable(path, []byte("#!/bin/sh\nexit 0\n")) + err := writeNativeProbeExecutable(path, nil) if err != nil { t.Fatalf("writeNativeProbeExecutable() error = %v", err) } @@ -56,7 +59,7 @@ func TestNativeGitBindProbeCreatesExecutableFixtures(t *testing.T) { t.Fatalf("nativeGitBindProbe() error = %v", err) } - for _, path := range []string{files.policyGit, files.realGit, files.targetGit} { + for _, path := range []string{files.realGitBind, files.targetGit} { info, statErr := os.Stat(path) if statErr != nil { t.Fatalf("stat native git probe fixture %s: %v", path, statErr) @@ -64,17 +67,41 @@ func TestNativeGitBindProbeCreatesExecutableFixtures(t *testing.T) { if info.Mode().Perm() != nativeProbeFileMode { t.Fatalf("probe fixture mode for %s = %s", path, info.Mode().Perm()) } + if info.Size() != 0 { + t.Fatalf("probe fixture %s size = %d, want zero", path, info.Size()) + } } - info, statErr := os.Stat(files.realGitBind) - if statErr != nil { - t.Fatalf("stat real git bind fixture: %v", statErr) + command := exec.Command(files.targetGit, sandboxexec.NativeGitBindProbeMode) + command.Dir = repoRoot + if runErr := command.Run(); runErr == nil { + t.Fatal("unbound zero-byte git target unexpectedly executed") } - if info.Mode().Perm() != nativeProbeFileMode { + + wrapperMarker := filepath.Join(repoRoot, ".coding-ethos", "cache", "git-wrapper") + if _, statErr := os.Stat(wrapperMarker); !os.IsNotExist(statErr) { + t.Fatalf("unbound target emitted wrapper marker: %v", statErr) + } +} + +func TestPrepareNativeProbeRepoCreatesPrivateWriteRoot(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + if err := prepareNativeProbeRepo(repoRoot); err != nil { + t.Fatalf("prepareNativeProbeRepo() error = %v", err) + } + + writeRoot := filepath.Join(repoRoot, ".coding-ethos", "cache") + info, err := os.Stat(writeRoot) + if err != nil { + t.Fatalf("stat native probe write root: %v", err) + } + if !info.IsDir() || info.Mode().Perm() != nativeProbeDirMode { t.Fatalf( - "real git bind fixture mode = %s, want %o", - info.Mode().Perm(), - nativeProbeFileMode, + "native probe write root = %s, want private directory %o", + info.Mode(), + nativeProbeDirMode, ) } } diff --git a/go/internal/sandbox/sandbox_test.go b/go/internal/sandbox/sandbox_test.go index 35933fbe..f8c2507b 100644 --- a/go/internal/sandbox/sandbox_test.go +++ b/go/internal/sandbox/sandbox_test.go @@ -384,7 +384,7 @@ func TestBuildPlanRejectsAgentShellRealGitBindOutsideRepo(t *testing.T) { } } -func TestBuildPlanReusesVerifiedActiveAgentShellSandbox(t *testing.T) { +func TestBuildPlanDoesNotReuseActiveAgentShellForDescendantRepo(t *testing.T) { requireLinuxSandbox(t) root := t.TempDir() @@ -428,12 +428,177 @@ func TestBuildPlanReusesVerifiedActiveAgentShellSandbox(t *testing.T) { if err != nil { t.Fatalf("sandbox.BuildPlan() error = %v", err) } - if plan.Executable != "/usr/bin/env" || - plan.Evidence.Reason != "reusing active agent-shell sandbox" { - t.Fatalf("active agent shell sandbox was not reused: %#v", plan) + if plan.Executable != wrapper || + plan.Evidence.Reason == "reusing active agent-shell sandbox" { + t.Fatalf("descendant repo reused active agent shell sandbox: %#v", plan) + } + for _, want := range []string{"--write-path", "--", "/usr/bin/env"} { + if !slices.Contains(plan.Args, want) { + t.Fatalf("descendant plan skipped native helper argument %q: %#v", want, plan.Args) + } } if !plan.Evidence.Enabled || !plan.Evidence.RepoReadOnly { - t.Fatalf("active agent shell reuse lost sandbox evidence: %#v", plan.Evidence) + t.Fatalf("descendant plan lost sandbox evidence: %#v", plan.Evidence) + } +} + +func TestBuildPlanReusesVerifiedActiveAgentShellSandboxForExactRoot(t *testing.T) { + requireLinuxSandbox(t) + + root := t.TempDir() + wrapper := filepath.Join(root, "bin", "coding-ethos-sandbox") + realGitBind := filepath.Join( + root, + ".coding-ethos", + "cache", + "agent-shell", + "run-1", + "real-git", + ) + if err := os.MkdirAll(filepath.Dir(realGitBind), 0o700); err != nil { + t.Fatalf("create real git bind dir: %v", err) + } + writeExecutable(t, wrapper) + writeExecutable(t, realGitBind) + + t.Setenv("CODING_ETHOS_AGENT_SHELL_SANDBOX", "1") + t.Setenv("CODING_ETHOS_SANDBOX_ACTIVE", "1") + t.Setenv("CODING_ETHOS_SANDBOX_ROOT", root) + t.Setenv("CODING_ETHOS_REAL_GIT", realGitBind) + + plan, err := sandbox.BuildPlan(sandbox.Request{ + Tool: "ruff", + Executable: "/usr/bin/env", + WrapperPath: wrapper, + Cwd: root, + RepoRoot: root, + Args: []string{"true"}, + Capabilities: sandbox.Capabilities{ + SandboxProfile: "lint-offline", + RequiresProcesses: true, + WritePaths: []string{".coding-ethos/cache"}, + }, + }) + if err != nil { + t.Fatalf("sandbox.BuildPlan() error = %v", err) + } + if plan.Executable != wrapper || + plan.Evidence.Reason != "reusing active agent-shell sandbox" { + t.Fatalf("exact-root active agent shell sandbox was not reused: %#v", plan) + } + for _, want := range []string{"--write-path", "--", "/usr/bin/env"} { + if !slices.Contains(plan.Args, want) { + t.Fatalf("active reuse skipped native helper argument %q: %#v", want, plan.Args) + } + } +} + +func TestBuildPlanDoesNotReuseActiveAgentShellForNewGitBinds(t *testing.T) { + requireLinuxSandbox(t) + + root := t.TempDir() + repo := filepath.Join(root, sandbox.SandboxTempWritePath, "git-bind-probe") + wrapper := filepath.Join(root, "bin", "coding-ethos-sandbox") + realGitBind := filepath.Join(repo, ".coding-ethos", "cache", "real-git") + targetGit := filepath.Join(repo, ".coding-ethos", "cache", "target-git") + activeRealGit := filepath.Join( + root, + ".coding-ethos", + "cache", + "agent-shell", + "run-1", + "real-git", + ) + for _, path := range []string{repo, filepath.Dir(realGitBind), filepath.Dir(activeRealGit)} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("create git bind test directory %s: %v", path, err) + } + } + for _, path := range []string{wrapper, realGitBind, targetGit, activeRealGit} { + writeExecutable(t, path) + } + + t.Setenv("CODING_ETHOS_AGENT_SHELL_SANDBOX", "1") + t.Setenv("CODING_ETHOS_SANDBOX_ACTIVE", "1") + t.Setenv("CODING_ETHOS_SANDBOX_ROOT", root) + t.Setenv("CODING_ETHOS_REAL_GIT", activeRealGit) + + plan, err := sandbox.BuildPlan(sandbox.Request{ + Tool: "sandbox-git-bind-probe", + Executable: targetGit, + WrapperPath: wrapper, + Cwd: repo, + RepoRoot: repo, + Args: []string{"probe"}, + Capabilities: sandbox.Capabilities{ + SandboxProfile: "native-git-bind-probe", + GitWrapperPath: wrapper, + RealGitPath: wrapper, + RealGitBindPath: realGitBind, + GitTargetPaths: []string{targetGit}, + WritePaths: []string{".coding-ethos/cache"}, + RequiresGit: true, + }, + }) + if err != nil { + t.Fatalf("sandbox.BuildPlan() error = %v", err) + } + if plan.Executable != wrapper || + plan.Evidence.Reason == "reusing active agent-shell sandbox" { + t.Fatalf("git bind request reused active sandbox: %#v", plan) + } + for _, want := range []string{"--git-wrapper", "--real-git-path", "--git-target"} { + if !slices.Contains(plan.Args, want) { + t.Fatalf("git bind plan missing %q: %#v", want, plan.Args) + } + } +} + +func TestBuildPlanDoesNotReuseActiveAgentShellForNativeProbe(t *testing.T) { + requireLinuxSandbox(t) + + root := t.TempDir() + repo := filepath.Join(root, sandbox.SandboxTempWritePath, "native-probe") + wrapper := filepath.Join(root, "bin", "coding-ethos-sandbox") + activeRealGit := filepath.Join( + root, + ".coding-ethos", + "cache", + "agent-shell", + "run-1", + "real-git", + ) + for _, path := range []string{repo, filepath.Dir(activeRealGit)} { + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatalf("create native probe test directory %s: %v", path, err) + } + } + writeExecutable(t, wrapper) + writeExecutable(t, activeRealGit) + + t.Setenv("CODING_ETHOS_AGENT_SHELL_SANDBOX", "1") + t.Setenv("CODING_ETHOS_SANDBOX_ACTIVE", "1") + t.Setenv("CODING_ETHOS_SANDBOX_ROOT", root) + t.Setenv("CODING_ETHOS_REAL_GIT", activeRealGit) + + plan, err := sandbox.BuildPlan(sandbox.Request{ + Tool: "sandbox-probe", + Executable: "/bin/sh", + WrapperPath: wrapper, + Cwd: repo, + RepoRoot: repo, + Args: []string{"-c", "exit 0"}, + Capabilities: sandbox.Capabilities{ + SandboxProfile: "native-probe", + WritePaths: []string{".coding-ethos/cache"}, + }, + }) + if err != nil { + t.Fatalf("sandbox.BuildPlan() error = %v", err) + } + if plan.Executable != wrapper || + plan.Evidence.Reason == "reusing active agent-shell sandbox" { + t.Fatalf("native validator probe reused active sandbox: %#v", plan) } } diff --git a/go/internal/sandboxexec/filesystem_linux.go b/go/internal/sandboxexec/filesystem_linux.go index 655fd79d..34a060cd 100644 --- a/go/internal/sandboxexec/filesystem_linux.go +++ b/go/internal/sandboxexec/filesystem_linux.go @@ -35,13 +35,18 @@ var ( errGitTargetRequired = errors.New("at least one git target is required") errExecutableAbsolute = errors.New("executable path must be absolute") errExecutableInvalid = errors.New("executable path is not executable") + errNativeGitBindProbe = errors.New( + "native git bind probe executable is not an exact read-only mount", + ) ) const ( - privateDirMode = 0o700 - writableFileMode = 0o600 - mountInfoFieldCount = 6 - landlockWriteAccess = unix.LANDLOCK_ACCESS_FS_WRITE_FILE | + privateDirMode = 0o700 + writableFileMode = 0o600 + mountInfoFieldCount = 6 + nativeGitBindProbeMarker = ".coding-ethos/cache/git-wrapper" + nativeGitBindProbeFailureMarker = ".coding-ethos/cache/git-bind-not-read-only" + landlockWriteAccess = unix.LANDLOCK_ACCESS_FS_WRITE_FILE | unix.LANDLOCK_ACCESS_FS_REMOVE_DIR | unix.LANDLOCK_ACCESS_FS_REMOVE_FILE | unix.LANDLOCK_ACCESS_FS_MAKE_CHAR | @@ -57,6 +62,69 @@ const ( unix.LANDLOCK_ACCESS_FS_TRUNCATE ) +func runNativeGitBindProbe() error { + repoRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve native git bind probe root: %w", err) + } + + target, err := filepath.Abs(os.Args[0]) + if err != nil { + return fmt.Errorf("resolve native git bind probe executable: %w", err) + } + + mountInfo, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return fmt.Errorf("read native git bind probe mount info: %w", err) + } + + return validateNativeGitBindProbeMount(repoRoot, target, string(mountInfo)) +} + +func validateNativeGitBindProbeMount(repoRoot, target, mountInfo string) error { + if !readOnlyMountInfoForPath(mountInfo, target) { + err := writeNativeGitBindFailureMarker(repoRoot) + if err != nil { + return err + } + + return errNativeGitBindProbe + } + + err := writeNativeGitBindSuccessMarker(repoRoot) + if err != nil { + return err + } + + return nil +} + +func writeNativeGitBindFailureMarker(repoRoot string) error { + err := os.WriteFile( + filepath.Join(repoRoot, nativeGitBindProbeFailureMarker), + []byte("not-read-only\n"), + writableFileMode, + ) + if err != nil { + return fmt.Errorf("write native git bind failure marker: %w", err) + } + + return nil +} + +func writeNativeGitBindSuccessMarker(repoRoot string) error { + err := os.WriteFile( + filepath.Join(repoRoot, nativeGitBindProbeMarker), + []byte("wrapper\n"), + writableFileMode, + ) + if err != nil { + return fmt.Errorf("write native git bind success marker: %w", err) + } + + return nil +} + func applyFilesystemPolicy(options options) error { err := applyGitBindMounts(options) if err != nil { @@ -311,6 +379,12 @@ func readOnlyMountInfoForPath(content, path string) bool { return false } +// ReadOnlyMountInfoForPath reports whether path is an exact read-only Linux +// mountpoint in the supplied mountinfo content. +func ReadOnlyMountInfoForPath(content, path string) bool { + return readOnlyMountInfoForPath(content, path) +} + func applyGitBindMounts(options options) error { if strings.TrimSpace(options.gitWrapper) == "" && len(options.gitTargets) == 0 { return nil diff --git a/go/internal/sandboxexec/filesystem_other.go b/go/internal/sandboxexec/filesystem_other.go index 440783d7..db02eed9 100644 --- a/go/internal/sandboxexec/filesystem_other.go +++ b/go/internal/sandboxexec/filesystem_other.go @@ -5,6 +5,21 @@ package sandboxexec +import "errors" + +var errNativeGitBindProbeUnsupported = errors.New( + "native git bind probe requires Linux mount namespaces", +) + func applyFilesystemPolicy(options options) error { return nil } + +func runNativeGitBindProbe() error { + return errNativeGitBindProbeUnsupported +} + +// ReadOnlyMountInfoForPath is false where Linux mountinfo is unavailable. +func ReadOnlyMountInfoForPath(_, _ string) bool { + return false +} diff --git a/go/internal/sandboxexec/main.go b/go/internal/sandboxexec/main.go index 8e45702f..c54ea405 100644 --- a/go/internal/sandboxexec/main.go +++ b/go/internal/sandboxexec/main.go @@ -26,6 +26,8 @@ const ( sandboxExecFailureExitCode = 126 sandboxExecCommandName = "coding-ethos-sandbox" sandboxExecErrorPrefix = "coding-ethos-sandbox:" + // NativeGitBindProbeMode runs the compiled helper's fixed-path bind probe. + NativeGitBindProbeMode = "--internal-native-git-bind-probe" ) type repeatedPaths []string @@ -80,6 +82,10 @@ func Run(args []string) int { } func run(args []string) error { + if nativeGitBindProbeInvocation(args) { + return runNativeGitBindProbe() + } + config, err := parseOptions(args) if err != nil { return err @@ -98,6 +104,10 @@ func run(args []string) error { return execSandboxedCommand(config) } +func nativeGitBindProbeInvocation(args []string) bool { + return len(args) == 1 && args[0] == NativeGitBindProbeMode +} + func parseOptions(args []string) (options, error) { var ( parsed = options{paths: &sandboxPaths{}} diff --git a/go/internal/sandboxexec/main_test.go b/go/internal/sandboxexec/main_test.go index 4b5e08ca..dc643bfc 100644 --- a/go/internal/sandboxexec/main_test.go +++ b/go/internal/sandboxexec/main_test.go @@ -76,6 +76,85 @@ func TestRunReturnsFailureForInvalidInvocation(t *testing.T) { } } +func TestNativeGitBindProbeInvocationRequiresExactNoArgumentMode(t *testing.T) { + t.Parallel() + + if !nativeGitBindProbeInvocation([]string{NativeGitBindProbeMode}) { + t.Fatal("exact native git bind probe mode was not recognized") + } + for _, args := range [][]string{ + nil, + {}, + {NativeGitBindProbeMode, "extra"}, + {"other"}, + } { + if nativeGitBindProbeInvocation(args) { + t.Fatalf("non-exact native git bind probe invocation accepted: %#v", args) + } + } +} + +func TestValidateNativeGitBindProbeMountWritesOnlyFixedSuccessMarker(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + cacheRoot := filepath.Join(repoRoot, ".coding-ethos", "cache") + if err := os.MkdirAll(cacheRoot, privateDirMode); err != nil { + t.Fatalf("create probe cache: %v", err) + } + target := filepath.Join(cacheRoot, "target-git-probe") + mountInfo := "1 2 0:1 / " + target + " ro,relatime - tmpfs tmpfs rw\n" + + if err := validateNativeGitBindProbeMount(repoRoot, target, mountInfo); err != nil { + t.Fatalf("validateNativeGitBindProbeMount() error = %v", err) + } + + marker := filepath.Join(repoRoot, nativeGitBindProbeMarker) + content, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("read fixed native git bind marker: %v", err) + } + if strings.TrimSpace(string(content)) != "wrapper" { + t.Fatalf("native git bind marker = %q", content) + } + if _, err = os.Stat( + filepath.Join(repoRoot, nativeGitBindProbeFailureMarker), + ); !os.IsNotExist( + err, + ) { + t.Fatalf("read-only bind emitted failure marker: %v", err) + } +} + +func TestValidateNativeGitBindProbeMountRejectsWritableSelfBind(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + cacheRoot := filepath.Join(repoRoot, ".coding-ethos", "cache") + if err := os.MkdirAll(cacheRoot, privateDirMode); err != nil { + t.Fatalf("create probe cache: %v", err) + } + target := filepath.Join(cacheRoot, "target-git-probe") + mountInfo := "1 2 0:1 / " + target + " rw,relatime - tmpfs tmpfs rw\n" + + err := validateNativeGitBindProbeMount(repoRoot, target, mountInfo) + if !errors.Is(err, errNativeGitBindProbe) { + t.Fatalf("validateNativeGitBindProbeMount() error = %v, want read-only failure", err) + } + if _, err = os.Stat( + filepath.Join(repoRoot, nativeGitBindProbeMarker), + ); !os.IsNotExist( + err, + ) { + t.Fatalf("writable bind emitted success marker: %v", err) + } + if _, err = os.Stat( + filepath.Join(repoRoot, nativeGitBindProbeFailureMarker), + ); err != nil { + t.Fatalf("writable bind did not emit fixed failure marker: %v", err) + } +} + func TestExecSandboxedCommandReportsStartFailure(t *testing.T) { t.Parallel() diff --git a/pre-commit/PRE-COMMIT.md b/pre-commit/PRE-COMMIT.md index 77396139..577d8312 100644 --- a/pre-commit/PRE-COMMIT.md +++ b/pre-commit/PRE-COMMIT.md @@ -25,7 +25,7 @@ make cutover-verify In a consuming repo, run the same target from `coding-ethos/`. When `coding-ethos/` is a submodule, the root `Makefile` resolves the parent -repo automatically and installs hooks into the parent repo's `.git/hooks`. +repo automatically and installs hooks into Git's resolved hooks directory. Before the hook entrypoints are installed, `make install-hooks` also generates the consumer repo's `pyrightconfig.json`, `mypy.ini`, `ruff.toml`, `.pylintrc`, @@ -33,11 +33,12 @@ consumer repo's `pyrightconfig.json`, `mypy.ini`, `ruff.toml`, `.pylintrc`, `.coding-ethos/gemini/prompt-pack.json` from the shared bundle inputs plus any consuming-repo overrides. -`make install-hooks` installs `.git/hooks/pre-commit`, `pre-push`, and -`commit-msg` as small executable scripts that call -`bin/coding-ethos-run git-hook ` with the original Git arguments. The -runner repairs missing checkout-local runtime artifacts with `make build` and -dispatches to the built hook binary under `coding-ethos/bin/`. +`make install-hooks` installs `pre-commit`, `pre-push`, and `commit-msg` in +Git's resolved hooks directory as small executable scripts that call +`/coding-ethos-hooks/bin/coding-ethos-run git-hook ` with +the original Git arguments. Missing or stale runtime artifacts fail closed +with an explicit `make build` instruction; hook execution never builds or +repairs them implicitly. `make cutover-install` installs the Git hook entrypoints, syncs Claude, Codex, and Gemini repo-local agent hook settings, and then verifies the full cutover @@ -175,12 +176,19 @@ The selected `coding-ethos` authority builds the hook runtime: The current runtime model is documented in `docs/HOOK_RUNTIME_BOOTSTRAP.md`: installed consumer hooks are generated runner entrypoint scripts that dispatch to the stable Git-common -`.git/coding-ethos-hooks/` projection. `parent-install` atomically refreshes and -hash-verifies its compiled Go executables from the selected authority, while +`/coding-ethos-hooks/` projection. `parent-install` atomically +refreshes and hash-verifies its compiled Go executables from the selected authority, while `make build` refreshes the complete policy and toolchain projection. Hook execution therefore does not depend on the lifetime or visibility of one worktree checkout. +The compiled `parent-runtime-sync` command exclusively maintains the shared Go +executable inventory, including the root compatibility Git hook. It prunes +obsolete regular `coding-ethos-*` files, preserves unrelated files, rejects +managed symlinks/directories/special files, and installs same-directory +temporary files by mode-preserving atomic rename. Both local and consumer hook +entrypoints use the stable common runner. + The same wrapper also exposes local policy-runtime entrypoints: ```bash diff --git a/pre-commit/hooks/HOOKS.md b/pre-commit/hooks/HOOKS.md index 43113c19..ebee82c5 100644 --- a/pre-commit/hooks/HOOKS.md +++ b/pre-commit/hooks/HOOKS.md @@ -7,13 +7,22 @@ Go-backed Git hooks for coding-ethos bundles. Installed consumer repository shims are intentionally thin. They dispatch to the compiled runner under the repository's stable Git-common -`.git/coding-ethos-hooks/bin/` projection. The selected `coding-ethos` +`/coding-ethos-hooks/bin/` projection. The selected `coding-ethos` authority builds that projection; `parent-install` atomically refreshes and hash-verifies its Go executables, and `make build` refreshes the complete policy and toolchain runtime. Policy selection and strict freshness checks remain in compiled Coding Ethos code rather than in the shim, and no hook points at a worktree-local build path. +The compiled `parent-runtime-sync` command is the sole installer for shared Go +executables, including the root compatibility Git hook. It preserves unrelated +files, prunes obsolete regular `coding-ethos-*` files, rejects managed entries +with unsafe filesystem shapes, and publishes mode-preserving copies through a +same-directory temporary file plus atomic rename. Both the selected checkout's +resolved hook directory and consumer hook directories dispatch to this stable +common runner. Runtime absence or drift is repaired explicitly with +`make build`, never during hook execution. + The Go runner is the output-control layer for Git hooks. It supports `hooks.output_format` values of `auto`, `human`, `json`, and `toon`; `auto` selects TOON when known agent/LLM environment markers are present. Successful diff --git a/tests/test_makefile_contract_diagnostics.py b/tests/test_makefile_contract_diagnostics.py index 51771f4f..d18453a0 100644 --- a/tests/test_makefile_contract_diagnostics.py +++ b/tests/test_makefile_contract_diagnostics.py @@ -70,6 +70,14 @@ def test_tests_and_diagnostics_do_not_build_or_install_runtime() -> None: ) +def test_go_e2e_package_timeout_has_bounded_full_gate_budget() -> None: + makefile, _lines = _makefile_lines() + + assert "GO_TEST_TIMEOUT ?= 3m" in makefile + for target in ("go-e2e-test", "go-tools-coverage"): + assert '-timeout="$(GO_TEST_TIMEOUT)"' in _target_block(makefile, target) + + def test_normal_runtime_commands_require_prebuilt_artifacts() -> None: makefile, _lines = _makefile_lines() @@ -119,3 +127,60 @@ def test_check_blocks_unmanaged_go_module_root_binaries() -> None: clean_block = _target_block(makefile, "go-tools-clean") assert "$(GO_MODULE_ROOT_BINARY_OUTPUTS)" in clean_block assert 'rm -f "$(GO_TOOLS_DIR)/$$name"' in clean_block + + +def test_purrdf_extractor_uses_repository_target_directory() -> None: + makefile, _lines = _makefile_lines() + + for target in ("purrdf-extractor-check", "purrdf-extractor-install"): + target_body = _target_block(makefile, target) + assert ( + "--config 'build.build-dir=\"$(PURRDF_EXTRACTOR_DIR)/target\"'" + in target_body + ) + assert '--target-dir "$(PURRDF_EXTRACTOR_DIR)/target"' in target_body + + +def test_agent_hook_sync_uses_explicit_writable_state_roots() -> None: + makefile, _lines = _makefile_lines() + + assert "AGENT_HOOK_STATE_ROOT ?= $(LOCAL_BUILD_DIR)/agent-hooks-state" in makefile + assert ( + "CONSUMER_AGENT_HOOK_STATE_ROOT ?= " + "$(LOCAL_BUILD_DIR)/consumer-agent-hooks-state" in makefile + ) + + local_sync = _target_block(makefile, "_sync-agent-hooks") + assert 'CODEX_HOME="$(AGENT_HOOK_STATE_ROOT)/codex-home"' in local_sync + assert '--repo-root "$(REPO)"' in local_sync + assert '--state-root "$(AGENT_HOOK_STATE_ROOT)"' in local_sync + + consumer_sync = _target_block(makefile, "_sync-consumer-agent-hooks") + assert 'CODEX_HOME="$(CONSUMER_AGENT_HOOK_STATE_ROOT)/codex-home"' in consumer_sync + assert '--repo-root "$(HOOK_CONSUMER_ROOT)"' in consumer_sync + assert '--state-root "$(CONSUMER_AGENT_HOOK_STATE_ROOT)"' in consumer_sync + + +def test_parent_hook_runtime_executables_use_atomic_compiled_sync() -> None: + makefile, _lines = _makefile_lines() + + runtime_sync = _target_block(makefile, "_sync-parent-hook-runtime") + git_hook_sync = _target_block(makefile, "_sync-git-hooks") + + assert ( + '"$(GO_HOOK)" parent-runtime-sync --repo "$(HOOK_CONSUMER_ROOT)"' + in runtime_sync + ) + assert ( + "$(call install_git_hooks,$(LOCAL_HOOKS_DIR)," + "$(PARENT_HOOK_BIN_DIR)/coding-ethos-run)" in git_hook_sync + ) + + for forbidden in ( + 'cp "$(GO_TOOLS_BIN_DIR)"/coding-ethos-*', + 'cp "$(GO_TOOLS_BIN_DIR)/cerun"', + 'cp "$(GO_TOOLS_BIN_DIR)/lint"', + 'cp "$(GO_TOOLS_BIN_DIR)/coding-ethos-git-hook"', + "$(call install_git_hooks,$(LOCAL_HOOKS_DIR),$(GO_HOOK))", + ): + assert forbidden not in makefile From fbac01f62c4914ef161c9132a41d8bb3dd184bdc Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 1 Sep 2026 02:24:54 -0600 Subject: [PATCH 24/30] refactor(policy): simplify CEL diagnostic routing --- go/internal/evaluators/cel_expression.go | 52 ++++++++++++++++-------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/go/internal/evaluators/cel_expression.go b/go/internal/evaluators/cel_expression.go index 302041a9..cc642e84 100644 --- a/go/internal/evaluators/cel_expression.go +++ b/go/internal/evaluators/cel_expression.go @@ -159,42 +159,58 @@ func celDiagnostic( return diagnostic } + applyCELSpecializedDiagnostic( + &diagnostic, + context, + policyDef, + source, + activation, + ) + + if diagnostic.File == "" && diffLineWitness.found { + diagnostic.File = diffLineWitness.line.File + diagnostic.Line = int(diffLineWitness.line.Line) + diagnostic.Detail = diffLineWitness.line.Text + diagnostic.Metadata["diff_change_source"] = diffLineWitness.changeSource + } + + if diagnostic.File == "" && len(context.Files) == 1 { + diagnostic.File = context.Files[0] + } + + return diagnostic +} + +func applyCELSpecializedDiagnostic( + diagnostic *diagnostics.Diagnostic, + context Context, + policyDef policy.Policy, + source string, + activation map[string]any, +) { switch policyDef.ID { case filesystemLineLimitsPolicy: applyLineLimitFileDiagnostic( - &diagnostic, + diagnostic, activation, context.EvaluatorOptions, ) case hookChangedFileScopePolicy: - applyHookCommandDiagnostic(&diagnostic, activation) + applyHookCommandDiagnostic(diagnostic, activation) case similarCodeDetectedPolicy: - applySimilarityDiagnostic(&diagnostic, activation) + applySimilarityDiagnostic(diagnostic, activation) case pythonSuppressionWritePolicy: applyPythonSuppressionDiagnostic( - &diagnostic, + diagnostic, activation, context.EvaluatorOptions, ) default: if strings.Contains(source, "changed_symbols") || strings.Contains(source, "proposed_symbol_changes") { - applyGrowingSymbolDiagnostic(&diagnostic, activation) + applyGrowingSymbolDiagnostic(diagnostic, activation) } } - - if diagnostic.File == "" && diffLineWitness.found { - diagnostic.File = diffLineWitness.line.File - diagnostic.Line = int(diffLineWitness.line.Line) - diagnostic.Detail = diffLineWitness.line.Text - diagnostic.Metadata["diff_change_source"] = diffLineWitness.changeSource - } - - if diagnostic.File == "" && len(context.Files) == 1 { - diagnostic.File = context.Files[0] - } - - return diagnostic } type celDiffLineWitness struct { From be70ddae43c72a2b1356813d1456c9ae028b57c4 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 1 Sep 2026 02:43:39 -0600 Subject: [PATCH 25/30] fix(build): order hook runner before runtime sync --- Makefile | 2 +- ...makefile_contract_agent_skill_sync_is_not_user_facing.py | 4 ++-- tests/test_makefile_contract_diagnostics.py | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index b2f2a8ef..cdd8e0ea 100644 --- a/Makefile +++ b/Makefile @@ -668,7 +668,7 @@ _sync-git-hooks: ensure-go go-tools-install _sync-parent-hook-runtime $(call install_git_hooks,$(HOOKS_DIR),$(PARENT_HOOK_BIN_DIR)/coding-ethos-run); \ fi -_sync-parent-hook-runtime: ensure-go go-tools-install policy-bundle-install +_sync-parent-hook-runtime: ensure-go go-tools-install go-hook-runner-install policy-bundle-install @$(call print_step,Syncing parent hook runtime artifacts) @"$(GO_HOOK)" parent-runtime-sync --repo "$(HOOK_CONSUMER_ROOT)" @mkdir -p "$(PARENT_POLICY_DIR)" diff --git a/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py b/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py index 9b04d6e1..717de60f 100644 --- a/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py +++ b/tests/test_makefile_contract_agent_skill_sync_is_not_user_facing.py @@ -30,8 +30,8 @@ def test_agent_skill_sync_is_not_user_facing() -> None: in makefile ) assert ( - "_sync-parent-hook-runtime: ensure-go go-tools-install policy-bundle-install\n" - in makefile + "_sync-parent-hook-runtime: ensure-go go-tools-install " + "go-hook-runner-install policy-bundle-install\n" in makefile ) for target in ( "_sync-agent-skills", diff --git a/tests/test_makefile_contract_diagnostics.py b/tests/test_makefile_contract_diagnostics.py index d18453a0..a99e6e8e 100644 --- a/tests/test_makefile_contract_diagnostics.py +++ b/tests/test_makefile_contract_diagnostics.py @@ -164,9 +164,15 @@ def test_agent_hook_sync_uses_explicit_writable_state_roots() -> None: def test_parent_hook_runtime_executables_use_atomic_compiled_sync() -> None: makefile, _lines = _makefile_lines() + runtime_sync_target = next( + line + for line in makefile.splitlines() + if line.startswith("_sync-parent-hook-runtime:") + ) runtime_sync = _target_block(makefile, "_sync-parent-hook-runtime") git_hook_sync = _target_block(makefile, "_sync-git-hooks") + assert "go-hook-runner-install" in runtime_sync_target assert ( '"$(GO_HOOK)" parent-runtime-sync --repo "$(HOOK_CONSUMER_ROOT)"' in runtime_sync From eb0d640e2e9275a13396272cb5a0afcf5fdb4e82 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 8 Sep 2026 23:21:50 -0600 Subject: [PATCH 26/30] fix(runtime): harden parent authentication and store migration Gate the actionlint ShellCheck parent-executable check on the protocol path and split parent resolution into linux and unsupported build variants so non-Linux builds fail closed instead of trusting an unresolved parent. Prepare the parent hook runtime destination after the temporary executable is staged, replacing unsafe-shape rejection with directory cleanup so install is atomic rather than pre-validated. Reject future code intelligence schema versions before migrations run instead of only during search identity migration. Terminate Windows command process trees through taskkill and probe process liveness directly rather than relying on Kill. Pin the Go toolchain to go1.26.5. The managed golangci-lint is built against go1.26 and cannot read export data produced by a newer compiler, so a host running go1.27 panicked the linter. Hoist the denied sandbox plan literals into named values. A composite literal in a multi-value return formats differently under go1.26 and go1.27 gofmt, so gofmt-check and the managed gci formatter demanded opposite indentation for the same file. Scope coding_ethos.feedback_route to non-test sources. The rule governs agent-facing feedback surfaces, but matched any added stream write under go/internal, including test helper process IPC. Extract shellFilesFrom and resolveCommonShellHelperPrefixes to keep EvaluateShellBestPractices under the cyclomatic complexity ceiling. --- Makefile | 2 + README.md | 9 +- go.work | 2 + go/cmd/coding-ethos-run/dispatch.go | 36 +++- go/cmd/coding-ethos-run/main_test.go | 81 ++++++++- go/cmd/coding-ethos-run/parent_workflow.go | 36 ++-- .../policy_tool_parent_linux.go | 24 +++ .../policy_tool_parent_unsupported.go | 23 +++ go/go.mod | 2 + .../search_identity_migration_test.go | 6 +- go/internal/codeintel/store.go | 60 ++++++- go/internal/codeintel/store_migration_test.go | 49 +++++ go/internal/e2e/process_windows.go | 49 ++++- go/internal/e2e/process_windows_test.go | 168 ++++++++++++++++++ go/internal/evaluators/cel_expression.go | 19 +- go/internal/evaluators/cel_expression_test.go | 136 +++++++++++++- .../evaluators/shell_best_practices.go | 157 ++++++++++++---- .../evaluators/shell_best_practices_test.go | 119 ++++++++++++- go/internal/hooks/gate_exit_status.go | 16 +- go/internal/hooks/gate_exit_status_test.go | 5 + go/internal/sandbox/sandbox.go | 40 +++-- go/internal/sandbox/sandbox_test.go | 12 +- repo_ethos.yml | 1 + tests/test_makefile_contract_diagnostics.py | 5 + 24 files changed, 947 insertions(+), 110 deletions(-) create mode 100644 go/cmd/coding-ethos-run/policy_tool_parent_linux.go create mode 100644 go/cmd/coding-ethos-run/policy_tool_parent_unsupported.go create mode 100644 go/internal/e2e/process_windows_test.go diff --git a/Makefile b/Makefile index cdd8e0ea..e1c9eafd 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ UV ?= uv PYTHON ?= python GO ?= go GOFMT ?= gofmt +GO_TOOLCHAIN ?= go1.26.5 CARGO ?= cargo GO_BUILD_FLAGS ?= -trimpath -buildvcs=false GO_BUILD_CACHE_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/go-build @@ -41,6 +42,7 @@ UV_CACHE_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/uv export GOCACHE := $(GO_BUILD_CACHE_DIR) export GOPATH := $(GO_PATH_DIR) export GOMODCACHE := $(GO_MODULE_CACHE_DIR) +export GOTOOLCHAIN := $(GO_TOOLCHAIN) export UV_CACHE_DIR empty := diff --git a/README.md b/README.md index 5ca1cc17..3f17a9db 100644 --- a/README.md +++ b/README.md @@ -932,10 +932,11 @@ install/check emit only status plus artifact-step rows, while parent lint emits the normal coding-ethos TOON lint report. See `TO_MY_PARENT.md` for the parent artifact contract. -`parent-install` rebuilds the checkout-authoritative Go tools and atomically -projects byte-identical executables into the parent repository's stable common -Git runtime. `parent-check` hashes both sides and fails if that projection is -missing, non-executable, symlinked back to a retiring checkout, or stale. +`make build` rebuilds the checkout-authoritative Go tools. `parent-install` +atomically projects those already-built, byte-identical executables into the +parent repository's stable common Git runtime. `parent-check` hashes both +sides and fails if that projection is missing, non-executable, symlinked back +to a retiring checkout, or stale. `parent-runtime-sync` is the supervisor-safe repair path: it projects the already-built authoritative executables without rebuilding tools, rewriting generated parent artifacts, or refreshing code intelligence. diff --git a/go.work b/go.work index 4caee6e1..52a76e6b 100644 --- a/go.work +++ b/go.work @@ -3,6 +3,8 @@ go 1.26 +toolchain go1.26.5 + use ( ./go ) diff --git a/go/cmd/coding-ethos-run/dispatch.go b/go/cmd/coding-ethos-run/dispatch.go index a4bb5240..e3186810 100644 --- a/go/cmd/coding-ethos-run/dispatch.go +++ b/go/cmd/coding-ethos-run/dispatch.go @@ -1013,19 +1013,41 @@ func runAgentHooksCommand(paths runtimePaths, rest []string) { } func runPolicyTool(paths runtimePaths, rest []string) error { - parentExecutable := "" - - resolvedParent, err := os.Readlink( - filepath.Join("/proc", strconv.Itoa(os.Getppid()), "exe"), + return runPolicyToolWithParentResolver( + paths, + rest, + policyToolParentExecutable, ) - if err == nil { - parentExecutable = resolvedParent +} + +func runPolicyToolWithParentResolver( + paths runtimePaths, + rest []string, + resolveParent func() (string, error), +) error { + protocolMarker := os.Getenv(toolprotocol.ActionlintShellcheckEnv) + if len(rest) == 0 || !toolprotocol.IsActionlintShellcheckJSONStdin( + protocolMarker, + rest[0], + rest[1:], + ) { + return runPolicyToolForProtocol(paths, rest, protocolMarker, "") + } + + parentExecutable, err := resolveParent() + if err != nil { + requirePolicyBundle(paths) + + return fmt.Errorf( + "authenticate actionlint ShellCheck protocol parent: %w", + err, + ) } return runPolicyToolForProtocol( paths, rest, - os.Getenv(toolprotocol.ActionlintShellcheckEnv), + protocolMarker, parentExecutable, ) } diff --git a/go/cmd/coding-ethos-run/main_test.go b/go/cmd/coding-ethos-run/main_test.go index b9251104..2a1478fd 100644 --- a/go/cmd/coding-ethos-run/main_test.go +++ b/go/cmd/coding-ethos-run/main_test.go @@ -908,7 +908,7 @@ func TestParentHookRuntimeSyncConvergesAndCheckDetectsDrift(t *testing.T) { } } -func TestParentHookRuntimeCheckRejectsCheckoutSymlink(t *testing.T) { +func TestParentHookRuntimeSyncRepairsExpectedDestinationSymlinks(t *testing.T) { t.Parallel() paths := runtimeTestPaths(t) @@ -937,6 +937,27 @@ func TestParentHookRuntimeCheckRejectsCheckoutSymlink(t *testing.T) { !strings.Contains(err.Error(), "coding-ethos-run(not_regular)") { t.Fatalf("check symlink error = %v", err) } + + err = syncParentHookRuntimeExecutables(paths, options) + if err != nil { + t.Fatalf("repair expected runtime symlinks: %v", err) + } + + err = checkParentHookRuntimeExecutables(paths, options) + if err != nil { + t.Fatalf("check repaired expected runtime symlinks: %v", err) + } + + for _, tool := range parentGoToolFixtureCommands() { + destination := filepath.Join(runtimeBin, tool) + info, infoErr := os.Lstat(destination) + if infoErr != nil { + t.Fatalf("stat repaired %s: %v", tool, infoErr) + } + if !info.Mode().IsRegular() { + t.Fatalf("repaired %s is not a regular file: %v", tool, info.Mode()) + } + } } func TestParentHookRuntimeSyncPrunesOnlyStaleManagedExecutables(t *testing.T) { @@ -3015,6 +3036,64 @@ func TestPolicyToolRejectsSpoofedActionlintShellcheckProtocol(t *testing.T) { } } +func TestPolicyToolRejectsProtocolWhenParentIdentityCannotBeResolved( + t *testing.T, +) { + paths := runtimeTestPaths(t) + var calls []string + paths.Executor = stubRuntimeOps{calls: &calls} + t.Setenv( + toolprotocol.ActionlintShellcheckEnv, + toolprotocol.ActionlintShellcheckJSONStdinV1, + ) + + err := runPolicyToolWithParentResolver( + paths, + []string{"shellcheck", "-f", "json", "-"}, + func() (string, error) { + return "", apperror.StaticError("parent identity unavailable") + }, + ) + if err == nil || !strings.Contains( + err.Error(), + "authenticate actionlint ShellCheck protocol parent: parent identity unavailable", + ) { + t.Fatalf("unresolved parent identity error = %v", err) + } + if len(calls) != 0 { + t.Fatalf("unresolved parent identity executed a tool: %#v", calls) + } +} + +func TestPolicyToolDoesNotResolveParentWithoutActionlintProtocol(t *testing.T) { + paths := runtimeTestPaths(t) + var calls []string + paths.Executor = stubRuntimeOps{calls: &calls} + t.Setenv(toolprotocol.ActionlintShellcheckEnv, "") + called := false + + err := runPolicyToolWithParentResolver( + paths, + []string{"shellcheck", "-f", "json", "-"}, + func() (string, error) { + called = true + + return "", apperror.StaticError("parent identity unavailable") + }, + ) + if err != nil { + t.Fatalf("run direct shellcheck: %v", err) + } + if called { + t.Fatal("direct shellcheck resolved an irrelevant parent identity") + } + + joined := strings.Join(calls, "\n") + if !strings.Contains(joined, "exec-lint:") || strings.Contains(joined, "execpath:") { + t.Fatalf("direct shellcheck bypassed managed capture: %#v", calls) + } +} + func TestPolicyToolCapturesShellcheckWithoutActionlintMarker(t *testing.T) { paths := runtimeTestPaths(t) var calls []string diff --git a/go/cmd/coding-ethos-run/parent_workflow.go b/go/cmd/coding-ethos-run/parent_workflow.go index 8b497a62..f1ea68ed 100644 --- a/go/cmd/coding-ethos-run/parent_workflow.go +++ b/go/cmd/coding-ethos-run/parent_workflow.go @@ -495,7 +495,7 @@ func syncParentHookRuntimeExecutables( return err } - err = validateParentHookRuntimeProjections(projections) + err = validateParentHookRuntimeProjectionSources(projections) if err != nil { return err } @@ -519,17 +519,14 @@ func ensureParentHookRuntimeDirectories(directories []parentRuntimeDirectory) er return nil } -func validateParentHookRuntimeProjections(projections []parentRuntimeExecutable) error { +func validateParentHookRuntimeProjectionSources( + projections []parentRuntimeExecutable, +) error { for _, projection := range projections { _, err := parentRuntimeSourceInfo(projection.Source) if err != nil { return fmt.Errorf("validate parent hook runtime %s: %w", projection.Label, err) } - - err = validateParentHookRuntimeDestination(projection.Destination) - if err != nil { - return fmt.Errorf("validate parent hook runtime %s: %w", projection.Label, err) - } } return nil @@ -682,11 +679,6 @@ func installParentHookRuntimeExecutable(source, destination string) error { return err } - err = validateParentHookRuntimeDestination(destination) - if err != nil { - return err - } - input, err := os.Open(source) if err != nil { return fmt.Errorf("open source executable %s: %w", source, err) @@ -728,6 +720,11 @@ func installParentHookRuntimeExecutable(source, destination string) error { return fmt.Errorf("close temporary hook runtime executable: %w", err) } + err = prepareParentHookRuntimeDestination(destination) + if err != nil { + return err + } + err = os.Rename(temporaryPath, destination) if err != nil { return fmt.Errorf("activate hook runtime executable %s: %w", destination, err) @@ -749,7 +746,7 @@ func parentRuntimeSourceInfo(source string) (fs.FileInfo, error) { return info, nil } -func validateParentHookRuntimeDestination(destination string) error { +func prepareParentHookRuntimeDestination(destination string) error { info, err := os.Lstat(destination) if errors.Is(err, os.ErrNotExist) { return nil @@ -759,8 +756,17 @@ func validateParentHookRuntimeDestination(destination string) error { return fmt.Errorf("stat installed executable %s: %w", destination, err) } - if !info.Mode().IsRegular() { - return fmt.Errorf("%w: %s", errParentRuntimeUnsafeShape, destination) + if !info.IsDir() { + return nil + } + + err = os.Remove(destination) + if err != nil { + return fmt.Errorf( + "remove expected parent hook runtime directory %s: %w", + destination, + err, + ) } return nil diff --git a/go/cmd/coding-ethos-run/policy_tool_parent_linux.go b/go/cmd/coding-ethos-run/policy_tool_parent_linux.go new file mode 100644 index 00000000..5e852a77 --- /dev/null +++ b/go/cmd/coding-ethos-run/policy_tool_parent_linux.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build linux + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" +) + +func policyToolParentExecutable() (string, error) { + parentExecutable, err := os.Readlink( + filepath.Join("/proc", strconv.Itoa(os.Getppid()), "exe"), + ) + if err != nil { + return "", fmt.Errorf("resolve parent executable identity: %w", err) + } + + return parentExecutable, nil +} diff --git a/go/cmd/coding-ethos-run/policy_tool_parent_unsupported.go b/go/cmd/coding-ethos-run/policy_tool_parent_unsupported.go new file mode 100644 index 00000000..9fc14ab6 --- /dev/null +++ b/go/cmd/coding-ethos-run/policy_tool_parent_unsupported.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build !linux + +package main + +import ( + "fmt" + "runtime" + + "blackcat.ca/coding-ethos/go/internal/apperror" +) + +func policyToolParentExecutable() (string, error) { + return "", fmt.Errorf( + "%w on %s", + apperror.StaticError( + "actionlint ShellCheck protocol is unsupported because parent executable identity cannot be authenticated", + ), + runtime.GOOS, + ) +} diff --git a/go/go.mod b/go/go.mod index dfd27d8d..7c92bf9b 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,6 +2,8 @@ module blackcat.ca/coding-ethos/go go 1.26 +toolchain go1.26.5 + require go.yaml.in/yaml/v3 v3.0.4 require github.com/pelletier/go-toml/v2 v2.3.1 diff --git a/go/internal/codeintel/search_identity_migration_test.go b/go/internal/codeintel/search_identity_migration_test.go index b70efa54..9ef863a5 100644 --- a/go/internal/codeintel/search_identity_migration_test.go +++ b/go/internal/codeintel/search_identity_migration_test.go @@ -142,8 +142,8 @@ func TestRoutineV2SearchIdentityMigrationSkipsRecoveryScan(t *testing.T) { `INSERT INTO code_intel_fts( fts_id, kind, record_id, message, search_text ) VALUES - ('v2:conflict', 'finding', 'one', 'first', 'same text'), - ('v2:conflict', 'finding', 'one', 'second', 'same text')`, + ('v2:replay', 'finding', 'one', 'same', 'same text'), + ('v2:replay', 'finding', 'one', 'same', 'same text')`, ) if err != nil { t.Fatalf("insert v2 recovery sentinel: %v", err) @@ -156,7 +156,7 @@ func TestRoutineV2SearchIdentityMigrationSkipsRecoveryScan(t *testing.T) { var rows int if err = store.Database().QueryRowContext( ctx, - "SELECT COUNT(*) FROM code_intel_fts WHERE fts_id = 'v2:conflict'", + "SELECT COUNT(*) FROM code_intel_fts WHERE fts_id = 'v2:replay'", ).Scan(&rows); err != nil { t.Fatalf("count retained v2 recovery sentinel: %v", err) } diff --git a/go/internal/codeintel/store.go b/go/internal/codeintel/store.go index 989cc501..995022b4 100644 --- a/go/internal/codeintel/store.go +++ b/go/internal/codeintel/store.go @@ -531,6 +531,11 @@ func configureReadOnlyStore(ctx context.Context, database *sql.DB) error { } func migrateStore(ctx context.Context, database *sql.DB) error { + err := rejectFutureCodeIntelSchemaVersion(ctx, database) + if err != nil { + return err + } + for _, statement := range duckDBSchemaStatements() { _, inlineErrB := database.ExecContext(ctx, statement) if inlineErrB != nil { @@ -538,7 +543,7 @@ func migrateStore(ctx context.Context, database *sql.DB) error { } } - err := migrateSearchIdentity(ctx, database) + err = migrateSearchIdentity(ctx, database) if err != nil { return err } @@ -582,18 +587,57 @@ func migrateSearchIdentity(ctx context.Context, database *sql.DB) error { } if version > schemaVersion { - return fmt.Errorf( - "migrate code intelligence store: schema version %d "+ - "exceeds supported version %d: %w", - version, - schemaVersion, - errStoreMigrationIntegrity, - ) + return unsupportedCodeIntelSchemaVersionError(version) } return deduplicateSearchIdentity(ctx, database) } +func rejectFutureCodeIntelSchemaVersion( + ctx context.Context, + database *sql.DB, +) error { + var metadataTableExists bool + + err := database.QueryRowContext( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = 'schema_metadata' + )`, + ).Scan(&metadataTableExists) + if err != nil { + return fmt.Errorf("inspect code intelligence schema metadata: %w", err) + } + + if !metadataTableExists { + return nil + } + + version, found, err := storedCodeIntelSchemaVersion(ctx, database) + if err != nil { + return err + } + + if found && version > schemaVersion { + return unsupportedCodeIntelSchemaVersionError(version) + } + + return nil +} + +func unsupportedCodeIntelSchemaVersionError(version int) error { + return fmt.Errorf( + "migrate code intelligence store: schema version %d "+ + "exceeds supported version %d: %w", + version, + schemaVersion, + errStoreMigrationIntegrity, + ) +} + func storedCodeIntelSchemaVersion( ctx context.Context, database *sql.DB, diff --git a/go/internal/codeintel/store_migration_test.go b/go/internal/codeintel/store_migration_test.go index 31c95b74..9fe3b012 100644 --- a/go/internal/codeintel/store_migration_test.go +++ b/go/internal/codeintel/store_migration_test.go @@ -327,6 +327,55 @@ func TestMigrateStoreRejectsUnexpectedSchemaVersion(t *testing.T) { } } +func TestOpenRejectsFutureSchemaBeforeDDLWrites(t *testing.T) { + ctx := context.Background() + path := DefaultDBPath(t.TempDir()) + store, err := Open(ctx, path) + if err != nil { + t.Fatalf("open future-schema fixture: %v", err) + } + + _, err = store.Database().ExecContext( + ctx, + `INSERT INTO code_intel_fts( + fts_id, kind, record_id, trace_id, search_text + ) VALUES ('', 'finding', 'future-record', 'future-trace', 'future schema')`, + ) + if err != nil { + t.Fatalf("insert future-schema write sentinel: %v", err) + } + _, err = store.Database().ExecContext( + ctx, + "UPDATE schema_metadata SET value = '999' WHERE key = 'schema_version'", + ) + if err != nil { + t.Fatalf("mark fixture as future schema: %v", err) + } + closeMigrationTestStore(t, store) + + _, err = Open(ctx, path) + if err == nil || !strings.Contains(err.Error(), "schema version 999") { + t.Fatalf("expected future schema rejection, got %v", err) + } + + database, err := sql.Open("duckdb", path+"?access_mode=READ_ONLY") + if err != nil { + t.Fatalf("open rejected future schema read-only: %v", err) + } + defer database.Close() + + var ftsID string + if err = database.QueryRowContext( + ctx, + "SELECT fts_id FROM code_intel_fts WHERE record_id = 'future-record'", + ).Scan(&ftsID); err != nil { + t.Fatalf("read future-schema write sentinel: %v", err) + } + if ftsID != "" { + t.Fatalf("future-schema rejection mutated fts_id to %q", ftsID) + } +} + func TestMigrateStoreRejectsExistingManifestBeforeOpeningDestination(t *testing.T) { ctx := context.Background() repositoryRoot := t.TempDir() diff --git a/go/internal/e2e/process_windows.go b/go/internal/e2e/process_windows.go index 0dc7a2be..f88b47cc 100644 --- a/go/internal/e2e/process_windows.go +++ b/go/internal/e2e/process_windows.go @@ -6,14 +6,22 @@ package e2e import ( + "context" "errors" + "fmt" "os" "os/exec" + "strconv" + + "golang.org/x/sys/windows" ) func configureCommandProcessGroup(_ *exec.Cmd) {} func configureCommandCancellation(cmd *exec.Cmd) { + cmd.Cancel = func() error { + return terminateCommandProcessGroup(cmd) + } cmd.WaitDelay = commandWaitDelay } @@ -22,10 +30,45 @@ func terminateCommandProcessGroup(cmd *exec.Cmd) error { return os.ErrProcessDone } - err := cmd.Process.Kill() - if errors.Is(err, os.ErrProcessDone) { + if windowsProcessExited(cmd.Process.Pid) { + return os.ErrProcessDone + } + + terminationContext, cancel := context.WithTimeout( + context.Background(), + commandWaitDelay, + ) + defer cancel() + + command := exec.CommandContext( + terminationContext, + "taskkill.exe", + "/PID", strconv.Itoa(cmd.Process.Pid), + "/T", + "/F", + ) + err := command.Run() + if err == nil { + return nil + } + if windowsProcessExited(cmd.Process.Pid) { return os.ErrProcessDone } - return err + return fmt.Errorf("terminate command process tree %d: %w", cmd.Process.Pid, err) +} + +func windowsProcessExited(pid int) bool { + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return true + } + if err != nil { + return false + } + defer func() { _ = windows.CloseHandle(handle) }() + + status, err := windows.WaitForSingleObject(handle, 0) + + return err == nil && status == windows.WAIT_OBJECT_0 } diff --git a/go/internal/e2e/process_windows_test.go b/go/internal/e2e/process_windows_test.go new file mode 100644 index 00000000..0c25f894 --- /dev/null +++ b/go/internal/e2e/process_windows_test.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build windows + +package e2e + +import ( + "bufio" + "context" + "errors" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/sys/windows" +) + +const ( + windowsProcessTreeHelperMode = "CODING_ETHOS_E2E_WINDOWS_PROCESS_TREE_HELPER" + windowsProcessTreeChildPID = "child-pid=" +) + +type windowsProcessTreeReady struct { + Err error + Line string +} + +func TestCommandCancellationTerminatesWindowsProcessTree(t *testing.T) { + if mode := os.Getenv(windowsProcessTreeHelperMode); mode != "" { + runWindowsProcessTreeHelper(t, mode) + + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cmd := exec.CommandContext( + ctx, + os.Args[0], + "-test.run=^TestCommandCancellationTerminatesWindowsProcessTree$", + ) + cmd.Env = append(os.Environ(), windowsProcessTreeHelperMode+"=parent") + configureCommandProcessGroup(cmd) + configureCommandCancellation(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("open process-tree stdout: %v", err) + } + cmd.Stderr = os.Stderr + + if err = cmd.Start(); err != nil { + t.Fatalf("start process-tree helper: %v", err) + } + + ready := make(chan windowsProcessTreeReady, 1) + go func() { + line, readErr := bufio.NewReader(stdout).ReadString('\n') + ready <- windowsProcessTreeReady{Err: readErr, Line: line} + }() + + var childPID int + select { + case result := <-ready: + if result.Err != nil { + cancel() + _ = cmd.Wait() + t.Fatalf("process-tree helper did not become ready: %v", result.Err) + } + childPID = windowsProcessTreeHelperChildPID(t, result.Line) + case <-time.After(10 * time.Second): + cancel() + _ = cmd.Wait() + t.Fatal("process-tree helper readiness timed out") + } + + cancel() + waited := make(chan error, 1) + go func() { waited <- cmd.Wait() }() + + select { + case err = <-waited: + if err == nil || !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("canceled process tree returned err=%v context=%v", err, ctx.Err()) + } + case <-time.After(5 * time.Second): + t.Fatal("canceled process tree did not return") + } + + assertWindowsProcessExited(t, childPID) +} + +func runWindowsProcessTreeHelper(t *testing.T, mode string) { + t.Helper() + + if mode == "child" { + time.Sleep(30 * time.Second) + + return + } + if mode != "parent" { + t.Fatalf("unknown process-tree helper mode %q", mode) + } + + child := exec.Command( + os.Args[0], + "-test.run=^TestCommandCancellationTerminatesWindowsProcessTree$", + ) + child.Env = append(os.Environ(), windowsProcessTreeHelperMode+"=child") + child.Stdout = os.Stdout + child.Stderr = os.Stderr + if err := child.Start(); err != nil { + t.Fatalf("start process-tree child: %v", err) + } + if _, err := os.Stdout.WriteString( + windowsProcessTreeChildPID + strconv.Itoa(child.Process.Pid) + "\n", + ); err != nil { + t.Fatalf("write process-tree readiness: %v", err) + } + time.Sleep(30 * time.Second) +} + +func windowsProcessTreeHelperChildPID(t *testing.T, line string) int { + t.Helper() + + value, found := strings.CutPrefix( + strings.TrimSpace(line), + windowsProcessTreeChildPID, + ) + if !found { + t.Fatalf("invalid process-tree readiness line %q", line) + } + pid, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("parse process-tree child PID %q: %v", value, err) + } + + return pid +} + +func assertWindowsProcessExited(t *testing.T, pid int) { + t.Helper() + + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return + } + if err != nil { + t.Fatalf("open process-tree child %d: %v", pid, err) + } + defer func() { _ = windows.CloseHandle(handle) }() + + status, err := windows.WaitForSingleObject( + handle, + uint32((5*time.Second)/time.Millisecond), + ) + if err != nil { + t.Fatalf("wait for process-tree child %d: %v", pid, err) + } + if status != windows.WAIT_OBJECT_0 { + t.Fatalf("process-tree child %d survived cancellation", pid) + } +} diff --git a/go/internal/evaluators/cel_expression.go b/go/internal/evaluators/cel_expression.go index cc642e84..f96e9bdf 100644 --- a/go/internal/evaluators/cel_expression.go +++ b/go/internal/evaluators/cel_expression.go @@ -42,6 +42,9 @@ const ( scriptsPrefix = "scripts/" ) +// maxIsolatedDiffLineWitnessCandidates bounds best-effort diagnostic enrichment. +const maxIsolatedDiffLineWitnessCandidates = 256 + var celQuotedGlobPattern = regexp.MustCompile( `"([a-z]+(?:_\*)?)"`, ) @@ -85,9 +88,9 @@ func EvaluateCELExpression( return nil, nil } - diffLineWitness, err := isolatedDiffLineWitness(program, activation) - if err != nil { - return nil, fmt.Errorf("locate matched CEL diff line: %w", err) + diffLineWitness, witnessErr := isolatedDiffLineWitness(program, activation) + if witnessErr != nil { + diffLineWitness = celDiffLineWitness{} } decisionMode := strings.TrimSpace(policyDef.DefaultSeverity) @@ -235,7 +238,13 @@ func isolatedDiffLineWitness( activation map[string]any, ) (celDiffLineWitness, error) { diff, ok := activation["diff"].(celexpr.DiffInput) - if !ok || len(diff.AddedLines)+len(diff.RemovedLines) == 0 { + if !ok { + return celDiffLineWitness{}, nil + } + + candidateCount := len(diff.AddedLines) + len(diff.RemovedLines) + if candidateCount == 0 || + candidateCount > maxIsolatedDiffLineWitnessCandidates { return celDiffLineWitness{}, nil } @@ -255,7 +264,7 @@ func isolatedDiffLineWitness( candidates := make( []celDiffLineWitness, 0, - len(diff.AddedLines)+len(diff.RemovedLines), + candidateCount, ) for _, line := range diff.AddedLines { candidates = append(candidates, celDiffLineWitness{ diff --git a/go/internal/evaluators/cel_expression_test.go b/go/internal/evaluators/cel_expression_test.go index 9f69dead..6cc682b7 100644 --- a/go/internal/evaluators/cel_expression_test.go +++ b/go/internal/evaluators/cel_expression_test.go @@ -21,7 +21,8 @@ import ( ) const ( - lineLimitAdvice = "Do not make cosmetic or documentation-only edits just to " + + celDiffLineWitnessCandidateLimit = 256 + lineLimitAdvice = "Do not make cosmetic or documentation-only edits just to " + "satisfy the limit; apply SOLID refactoring and split the file into " + "focused modules before committing." lineLimitFile = "app.py" @@ -480,6 +481,139 @@ func TestEvaluateCELExpressionDiffRuleLocatesMatchedLine(t *testing.T) { } } +func TestEvaluateCELExpressionDiffRuleDiscardsAmbiguousWitness(t *testing.T) { + t.Parallel() + + diagnostic := evaluateCELDiffLinePolicy( + t, + "ambiguous.go", + []string{"blocked first", "blocked second"}, + `diff.added_lines.exists(line, line.text.contains("blocked"))`, + ) + + if diagnostic.File != "ambiguous.go" || diagnostic.Line != 0 { + t.Fatalf("diagnostic = %#v, want ambiguous.go fallback without line", diagnostic) + } + if _, found := diagnostic.Metadata["diff_change_source"]; found { + t.Fatalf("ambiguous diagnostic has diff witness metadata: %#v", diagnostic) + } +} + +func TestEvaluateCELExpressionDiffRuleKeepsDecisionWhenWitnessFails( + t *testing.T, +) { + t.Parallel() + + diagnostic := evaluateCELDiffLinePolicy( + t, + "indexed.go", + []string{"blocked"}, + `diff.added_lines[0].text.contains("blocked")`, + ) + + if diagnostic.File != "indexed.go" || diagnostic.Line != 0 { + t.Fatalf("diagnostic = %#v, want indexed.go fallback without line", diagnostic) + } + if _, found := diagnostic.Metadata["diff_change_source"]; found { + t.Fatalf("failed witness has diff witness metadata: %#v", diagnostic) + } +} + +func TestEvaluateCELExpressionDiffRuleBoundsWitnessCandidates(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + count int + witnessLine int + }{ + { + name: "at limit", + count: celDiffLineWitnessCandidateLimit, + witnessLine: celDiffLineWitnessCandidateLimit, + }, + { + name: "above limit", + count: celDiffLineWitnessCandidateLimit + 1, + }, + } { + t.Run(test.name, func(t *testing.T) { + lines := make([]string, test.count) + for index := range lines { + lines[index] = "ordinary" + } + lines[len(lines)-1] = "blocked" + + diagnostic := evaluateCELDiffLinePolicy( + t, + "bounded.go", + lines, + `diff.added_lines.exists(line, line.text == "blocked")`, + ) + + if diagnostic.File != "bounded.go" || diagnostic.Line != test.witnessLine { + t.Fatalf( + "diagnostic = %#v, want bounded.go:%d", + diagnostic, + test.witnessLine, + ) + } + _, found := diagnostic.Metadata["diff_change_source"] + if found != (test.witnessLine > 0) { + t.Fatalf("diagnostic witness metadata = %#v", diagnostic.Metadata) + } + }) + } +} + +func evaluateCELDiffLinePolicy( + t *testing.T, + file string, + addedLines []string, + when string, +) diagnostics.Diagnostic { + t.Helper() + + repo := t.TempDir() + runCELGit(t, repo, "init") + runCELGit(t, repo, "config", "user.email", "test@example.com") + runCELGit(t, repo, "config", "user.name", "Test User") + + path := filepath.Join(repo, file) + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("write baseline source: %v", err) + } + runCELGit(t, repo, "add", file) + runCELGit(t, repo, "commit", "-m", "initial") + + content := strings.Join(addedLines, "\n") + "\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write changed source: %v", err) + } + runCELGit(t, repo, "add", file) + + policyDef := celExpressionPolicy() + policyDef.ID = "feedback.route" + decisions, err := EvaluateCELExpression( + policyDef, + Context{ + Cwd: repo, + Files: []string{file}, + StagedFiles: []string{file}, + Scope: "staged", + EvaluatorOptions: map[string]any{"when": when}, + }, + ) + if err != nil { + t.Fatalf("evaluate CEL diff-line policy: %v", err) + } + if len(decisions) != 1 { + t.Fatalf("decisions = %#v, want one block", decisions) + } + + return decisions[0].Diagnostics[0] +} + func TestEvaluateCELExpressionBlocksAgentBrandingInCommitMessage(t *testing.T) { t.Parallel() diff --git a/go/internal/evaluators/shell_best_practices.go b/go/internal/evaluators/shell_best_practices.go index 17b054a4..f2d95a88 100644 --- a/go/internal/evaluators/shell_best_practices.go +++ b/go/internal/evaluators/shell_best_practices.go @@ -46,35 +46,16 @@ func EvaluateShellBestPractices( return nil, nil } - shellFiles := make([]string, 0, len(context.Files)) - for _, file := range context.Files { - if looksLikeShellFile(file) { - shellFiles = append(shellFiles, file) - } - } - + shellFiles := shellFilesFrom(context.Files) if len(shellFiles) == 0 { return nil, nil } - requireCommon := stringSliceOption( - context.EvaluatorOptions, - "require_common_for_prefixes", - []string{"scripts/"}, - ) - - hasCommonHelper, err := repositoryHasTrackedCommonShellHelper( - context.Cwd, - requireCommon, - ) + repositoryRoot, requireCommon, err := resolveCommonShellHelperPrefixes(context) if err != nil { return nil, err } - if !hasCommonHelper { - requireCommon = nil - } - for _, file := range shellFiles { text, binary, err := readShellText(file) if err != nil { @@ -85,7 +66,16 @@ func EvaluateShellBestPractices( continue } - violations := shellBestPracticeViolations(file, text, requireCommon) + comparisonPath, err := commonShellHelperComparisonPath(repositoryRoot, file) + if err != nil { + return nil, err + } + + violations := shellBestPracticeViolations( + comparisonPath, + text, + requireCommon, + ) if len(violations) > 0 { return []policy.Decision{ shellBestPracticesDecision(policyDef, file, violations), @@ -96,30 +86,68 @@ func EvaluateShellBestPractices( return nil, nil } -func repositoryHasTrackedCommonShellHelper( - cwd string, - requireCommonForPrefixes []string, -) (bool, error) { - if strings.TrimSpace(cwd) == "" { - return false, errShellHelperWorkingDirectoryRequired +func shellFilesFrom(files []string) []string { + shellFiles := make([]string, 0, len(files)) + for _, file := range files { + if looksLikeShellFile(file) { + shellFiles = append(shellFiles, file) + } + } + + return shellFiles +} + +// resolveCommonShellHelperPrefixes resolves the repository root and the +// directory prefixes that must source a tracked common shell helper. The +// returned prefixes are empty when the repository tracks no such helper. +func resolveCommonShellHelperPrefixes( + context Context, +) (string, []string, error) { + if strings.TrimSpace(context.Cwd) == "" { + return "", nil, errShellHelperWorkingDirectoryRequired } - repositoryRoot, err := gitWorktreeRoot(cwd) + repositoryRoot, err := gitWorktreeRoot(context.Cwd) if err != nil { - return false, fmt.Errorf( + return "", nil, fmt.Errorf( "resolve repository root for common shell helpers: %w", err, ) } + requireCommon := stringSliceOption( + context.EvaluatorOptions, + "require_common_for_prefixes", + []string{"scripts/"}, + ) + helperPaths, err := configuredCommonShellHelperPaths( repositoryRoot, - requireCommonForPrefixes, + requireCommon, ) if err != nil { - return false, err + return "", nil, err } + hasCommonHelper, err := repositoryHasTrackedCommonShellHelper( + repositoryRoot, + helperPaths, + ) + if err != nil { + return "", nil, err + } + + if !hasCommonHelper { + return repositoryRoot, nil, nil + } + + return repositoryRoot, commonShellHelperPrefixes(helperPaths), nil +} + +func repositoryHasTrackedCommonShellHelper( + repositoryRoot string, + helperPaths []string, +) (bool, error) { if len(helperPaths) == 0 { return false, nil } @@ -150,13 +178,61 @@ func repositoryHasTrackedCommonShellHelper( return false, nil } +func commonShellHelperPrefixes(helperPaths []string) []string { + prefixes := make([]string, 0, len(helperPaths)) + for _, helperPath := range helperPaths { + prefix := filepath.ToSlash(filepath.Dir(helperPath)) + if prefix != "." { + prefix += "/" + } + + prefixes = append(prefixes, prefix) + } + + return prefixes +} + +func commonShellHelperComparisonPath(repositoryRoot, path string) (string, error) { + if !filepath.IsAbs(path) { + return filepath.ToSlash(filepath.Clean(path)), nil + } + + normalizedRoot, err := filepath.EvalSymlinks(repositoryRoot) + if err != nil { + return "", fmt.Errorf("normalize common shell helper repository root: %w", err) + } + + normalizedPath, err := filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("normalize shell file path %q: %w", path, err) + } + + relativePath, err := filepath.Rel(normalizedRoot, normalizedPath) + if err != nil { + return "", fmt.Errorf( + "resolve shell file path %q from repository root: %w", + path, + err, + ) + } + + return filepath.ToSlash(relativePath), nil +} + func configuredCommonShellHelperPaths( - cwd string, + repositoryRoot string, prefixes []string, ) ([]string, error) { - repositoryRoot, err := filepath.Abs(cwd) + normalizedRepositoryRoot, err := filepath.Abs(repositoryRoot) if err != nil { - return nil, fmt.Errorf("resolve repository working directory: %w", err) + return nil, fmt.Errorf("resolve common shell helper repository root: %w", err) + } + + normalizedRepositoryRoot, err = filepath.EvalSymlinks( + normalizedRepositoryRoot, + ) + if err != nil { + return nil, fmt.Errorf("normalize common shell helper repository root: %w", err) } helperPaths := make([]string, 0, len(prefixes)) @@ -168,7 +244,16 @@ func configuredCommonShellHelperPaths( cleaned := filepath.Clean(trimmed) if filepath.IsAbs(cleaned) { - cleaned, err = filepath.Rel(repositoryRoot, cleaned) + cleaned, err = filepath.EvalSymlinks(cleaned) + if err != nil { + return nil, fmt.Errorf( + "normalize common shell helper prefix %q: %w", + prefix, + err, + ) + } + + cleaned, err = filepath.Rel(normalizedRepositoryRoot, cleaned) if err != nil { return nil, fmt.Errorf( "resolve common shell helper prefix %q: %w", diff --git a/go/internal/evaluators/shell_best_practices_test.go b/go/internal/evaluators/shell_best_practices_test.go index 679c6bcb..f6f92a27 100644 --- a/go/internal/evaluators/shell_best_practices_test.go +++ b/go/internal/evaluators/shell_best_practices_test.go @@ -300,27 +300,132 @@ func TestEvaluateShellBestPracticesResolvesCommonHelperFromNestedCWD( t.Fatalf("write script: %v", err) } + for _, test := range []struct { + name string + options map[string]any + }{ + { + name: "absolute prefix", + options: map[string]any{ + "require_common_for_prefixes": []any{ + scriptsDir + string(filepath.Separator), + }, + }, + }, + {name: "default relative prefix"}, + } { + t.Run(test.name, func(t *testing.T) { + decisions, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{ + Cwd: nestedDir, + Files: []string{scriptPath}, + EvaluatorOptions: test.options, + }, + ) + if err != nil { + t.Fatalf( + "evaluate common helper from nested working directory: %v", + err, + ) + } + if len(decisions) != 1 { + t.Fatalf("nested working directory decisions = %#v", decisions) + } + if got := decisions[0].Diagnostics[0].Message; got != + "scripts/ shell files must source the repository common shell helpers" { + t.Fatalf("diagnostic message = %q", got) + } + }) + } +} + +func TestEvaluateShellBestPracticesNormalizesSymlinkedAbsolutePrefix( + t *testing.T, +) { + t.Parallel() + + fixtureRoot := t.TempDir() + repo := filepath.Join(fixtureRoot, "repo") + if err := os.Mkdir(repo, 0o700); err != nil { + t.Fatalf("create repository: %v", err) + } + initializeStagedAdminGitRepo(t, repo) + + scriptsDir := filepath.Join(repo, "scripts") + nestedDir := filepath.Join(repo, "work", "nested") + for _, directory := range []string{scriptsDir, nestedDir} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatalf("create fixture directory %s: %v", directory, err) + } + } + + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + commonPath := filepath.Join(scriptsDir, "common.sh") + if err := os.WriteFile(commonPath, content, 0o600); err != nil { + t.Fatalf("write common helper: %v", err) + } + runGit(t, repo, "add", "scripts/common.sh") + + scriptPath := filepath.Join(scriptsDir, "work.sh") + if err := os.WriteFile(scriptPath, content, 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + repoAlias := filepath.Join(fixtureRoot, "repo-alias") + if err := os.Symlink(repo, repoAlias); err != nil { + t.Skipf("create repository symlink: %v", err) + } + decisions, err := EvaluateShellBestPractices( shellBestPracticesPolicy(), Context{ - Cwd: nestedDir, + Cwd: filepath.Join(repoAlias, "work", "nested"), Files: []string{scriptPath}, EvaluatorOptions: map[string]any{ "require_common_for_prefixes": []any{ - scriptsDir + string(filepath.Separator), + filepath.Join(repoAlias, "scripts") + string(filepath.Separator), }, }, }, ) if err != nil { - t.Fatalf("evaluate common helper from nested working directory: %v", err) + t.Fatalf("evaluate symlinked common helper prefix: %v", err) } if len(decisions) != 1 { - t.Fatalf("nested working directory decisions = %#v", decisions) + t.Fatalf("symlinked absolute prefix decisions = %#v", decisions) } - if got := decisions[0].Diagnostics[0].Message; got != - "scripts/ shell files must source the repository common shell helpers" { - t.Fatalf("diagnostic message = %q", got) +} + +func TestEvaluateShellBestPracticesRejectsAbsolutePrefixOutsideRepository( + t *testing.T, +) { + t.Parallel() + + repo := t.TempDir() + initializeStagedAdminGitRepo(t, repo) + scriptPath := filepath.Join(repo, "work.sh") + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + if err := os.WriteFile(scriptPath, content, 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + outsidePrefix := t.TempDir() + string(filepath.Separator) + _, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{ + Cwd: repo, + Files: []string{scriptPath}, + EvaluatorOptions: map[string]any{ + "require_common_for_prefixes": []any{outsidePrefix}, + }, + }, + ) + if err == nil { + t.Fatal("absolute prefix outside repository must fail validation") + } + if !strings.Contains(err.Error(), "outside the repository") { + t.Fatalf("outside repository error = %q", err) } } diff --git a/go/internal/hooks/gate_exit_status.go b/go/internal/hooks/gate_exit_status.go index 8dd46e9d..06c5cb7b 100644 --- a/go/internal/hooks/gate_exit_status.go +++ b/go/internal/hooks/gate_exit_status.go @@ -102,12 +102,26 @@ func maskedRequiredGateStatus( setting := pipefailCommandSetting( gateExecutableArgv(parsed.segments[index]), ) - pipefail = setting.apply(pipefail) + if parsed.segmentControlsShellState(index) { + pipefail = setting.apply(pipefail) + } } return "", false } +func (parsed gateShell) segmentControlsShellState(index int) bool { + if index < len(parsed.operators) { + switch parsed.operators[index] { + case "|", "|&", "&": + return false + } + } + + return index == 0 || + (parsed.operators[index-1] != "|" && parsed.operators[index-1] != "|&") +} + func (parsed gateShell) maskedSegmentStatus( index int, pipefail bool, diff --git a/go/internal/hooks/gate_exit_status_test.go b/go/internal/hooks/gate_exit_status_test.go index 6d75523c..bc52215f 100644 --- a/go/internal/hooks/gate_exit_status_test.go +++ b/go/internal/hooks/gate_exit_status_test.go @@ -73,6 +73,11 @@ func TestRequiredGateExitStatusTracksPipefailInExecutionOrder(t *testing.T) { inherited: true, masked: true, }, + { + name: "pipeline-local disable preserves inherited state", + command: "set +o pipefail | make check | tail -1", + inherited: true, + }, { name: "disable then enable", command: "set +o pipefail; set -o pipefail; make check | tail -1", diff --git a/go/internal/sandbox/sandbox.go b/go/internal/sandbox/sandbox.go index 3298f0ac..4b6ec42f 100644 --- a/go/internal/sandbox/sandbox.go +++ b/go/internal/sandbox/sandbox.go @@ -214,7 +214,7 @@ func BuildPlan(request Request) (Plan, error) { return deniedSeccompPlan(evidence, errNativeSeccompUnsupported) } - if verifiedActiveAgentShellSandboxCovers(request) && + if authenticatedActiveAgentShellSandboxCovers(request) && activeAgentShellSandboxReusable(request) { evidence.Reason = activeAgentShellReuseReason } @@ -246,6 +246,16 @@ func activeAgentShellSandboxReusable(request Request) bool { ) } +func authenticatedActiveAgentShellSandboxCovers(request Request) bool { + if !verifiedActiveAgentShellSandboxCovers(request) { + return false + } + + _, active, err := validateActiveAgentShellNativeRuntime(request.WrapperPath) + + return active && err == nil +} + func verifiedActiveAgentShellSandboxCovers(request Request) bool { if os.Getenv("CODING_ETHOS_AGENT_SHELL_SANDBOX") != "1" || os.Getenv("CODING_ETHOS_SANDBOX_ACTIVE") != "1" { @@ -283,13 +293,13 @@ func deniedSandboxPlan(evidence Evidence, cause error) (Plan, error) { evidence.Denied = true evidence.Reason = backendEvidenceReason(cause) - return Plan{ - Evidence: evidence, - }, fmt.Errorf( - "%w: %w", - ErrBackendUnavailable, - cause, - ) + plan := Plan{Evidence: evidence} + + return plan, fmt.Errorf( + "%w: %w", + ErrBackendUnavailable, + cause, + ) } func backendEvidenceReason(cause error) string { @@ -300,13 +310,13 @@ func deniedSeccompPlan(evidence Evidence, cause error) (Plan, error) { evidence.Denied = true evidence.Reason = cause.Error() - return Plan{ - Evidence: evidence, - }, fmt.Errorf( - "%w: %w", - ErrBackendUnavailable, - cause, - ) + plan := Plan{Evidence: evidence} + + return plan, fmt.Errorf( + "%w: %w", + ErrBackendUnavailable, + cause, + ) } func (plan Plan) Close() error { diff --git a/go/internal/sandbox/sandbox_test.go b/go/internal/sandbox/sandbox_test.go index f8c2507b..15e2d1a3 100644 --- a/go/internal/sandbox/sandbox_test.go +++ b/go/internal/sandbox/sandbox_test.go @@ -442,7 +442,7 @@ func TestBuildPlanDoesNotReuseActiveAgentShellForDescendantRepo(t *testing.T) { } } -func TestBuildPlanReusesVerifiedActiveAgentShellSandboxForExactRoot(t *testing.T) { +func TestBuildPlanDoesNotReuseSpoofedExactRootAgentShellSandbox(t *testing.T) { requireLinuxSandbox(t) root := t.TempDir() @@ -483,12 +483,16 @@ func TestBuildPlanReusesVerifiedActiveAgentShellSandboxForExactRoot(t *testing.T t.Fatalf("sandbox.BuildPlan() error = %v", err) } if plan.Executable != wrapper || - plan.Evidence.Reason != "reusing active agent-shell sandbox" { - t.Fatalf("exact-root active agent shell sandbox was not reused: %#v", plan) + plan.Evidence.Reason == "reusing active agent-shell sandbox" { + t.Fatalf("spoofed exact-root agent shell sandbox was reused: %#v", plan) } for _, want := range []string{"--write-path", "--", "/usr/bin/env"} { if !slices.Contains(plan.Args, want) { - t.Fatalf("active reuse skipped native helper argument %q: %#v", want, plan.Args) + t.Fatalf( + "spoofed exact-root plan skipped native helper argument %q: %#v", + want, + plan.Args, + ) } } } diff --git a/repo_ethos.yml b/repo_ethos.yml index 855b9a1f..0c112568 100644 --- a/repo_ethos.yml +++ b/repo_ethos.yml @@ -214,6 +214,7 @@ principles: diff.added_lines.exists(line, has_prefix(line.file, "go/internal/") && has_suffix(line.file, ".go") && + !has_suffix(line.file, "_test.go") && !has_prefix(line.file, "go/internal/feedback/") && ( line.text.contains("fmt.Fprint") || diff --git a/tests/test_makefile_contract_diagnostics.py b/tests/test_makefile_contract_diagnostics.py index a99e6e8e..e91bd621 100644 --- a/tests/test_makefile_contract_diagnostics.py +++ b/tests/test_makefile_contract_diagnostics.py @@ -71,6 +71,7 @@ def test_tests_and_diagnostics_do_not_build_or_install_runtime() -> None: def test_go_e2e_package_timeout_has_bounded_full_gate_budget() -> None: + """Keep package-level Go E2E timeouts bounded for full-gate load.""" makefile, _lines = _makefile_lines() assert "GO_TEST_TIMEOUT ?= 3m" in makefile @@ -79,6 +80,7 @@ def test_go_e2e_package_timeout_has_bounded_full_gate_budget() -> None: def test_normal_runtime_commands_require_prebuilt_artifacts() -> None: + """Require normal runtime targets to consume prebuilt artifacts.""" makefile, _lines = _makefile_lines() for target in ( @@ -130,6 +132,7 @@ def test_check_blocks_unmanaged_go_module_root_binaries() -> None: def test_purrdf_extractor_uses_repository_target_directory() -> None: + """Keep PurRDF extractor artifacts in its repository target directory.""" makefile, _lines = _makefile_lines() for target in ("purrdf-extractor-check", "purrdf-extractor-install"): @@ -142,6 +145,7 @@ def test_purrdf_extractor_uses_repository_target_directory() -> None: def test_agent_hook_sync_uses_explicit_writable_state_roots() -> None: + """Give agent-hook sync explicit checkout-local writable state roots.""" makefile, _lines = _makefile_lines() assert "AGENT_HOOK_STATE_ROOT ?= $(LOCAL_BUILD_DIR)/agent-hooks-state" in makefile @@ -162,6 +166,7 @@ def test_agent_hook_sync_uses_explicit_writable_state_roots() -> None: def test_parent_hook_runtime_executables_use_atomic_compiled_sync() -> None: + """Project parent hook executables through the atomic compiled sync path.""" makefile, _lines = _makefile_lines() runtime_sync_target = next( From dd6a7ad82b1c3e395266e0d8a0113dedbd758051 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 8 Sep 2026 23:40:03 -0600 Subject: [PATCH 27/30] fix(build): pin the Go toolchain to a patched 1.26 release go1.26.5 carries eight known stdlib advisories, including crypto/tls and net/http findings. Declaring the toolchain made OSV resolve stdlib@1.26.5 and report every one of them, so the dependency scan failed on a version the project had not previously named. Move to go1.26.8. All eight advisories are fixed as of 1.26.6, and the managed golangci-lint still reads its export data because the compiler stays on the 1.26 series. --- Makefile | 2 +- go.work | 2 +- go/go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index e1c9eafd..48109518 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ UV ?= uv PYTHON ?= python GO ?= go GOFMT ?= gofmt -GO_TOOLCHAIN ?= go1.26.5 +GO_TOOLCHAIN ?= go1.26.8 CARGO ?= cargo GO_BUILD_FLAGS ?= -trimpath -buildvcs=false GO_BUILD_CACHE_DIR ?= $(LOCAL_REPO_ROOT)/.coding-ethos/cache/go-build diff --git a/go.work b/go.work index 52a76e6b..7ac814f4 100644 --- a/go.work +++ b/go.work @@ -3,7 +3,7 @@ go 1.26 -toolchain go1.26.5 +toolchain go1.26.8 use ( ./go diff --git a/go/go.mod b/go/go.mod index 7c92bf9b..15a310d2 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,7 +2,7 @@ module blackcat.ca/coding-ethos/go go 1.26 -toolchain go1.26.5 +toolchain go1.26.8 require go.yaml.in/yaml/v3 v3.0.4 From a017a6e5285b7acb0fdcb9d8e6492ea53b6d102d Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Wed, 9 Sep 2026 00:08:52 -0600 Subject: [PATCH 28/30] fix(managedcapture): accept uv projects that are not installed yet sandboxUVProjectEnvironment resolved the uv project through EvalSymlinks and treated a missing directory as a hard error, so managed capture from a consumer root was denied before any tool ran. canonicalUVProjectPath in hookrunnercli already tolerated this case; the sandbox cache path did not, and the two must agree because both derive the same environment digest. Fall back to the lexical path when the project does not exist, matching the hook runner, and cover both the missing-project and no-project cases. Add coverage for the growing-symbol diagnostic helpers, which decide whether a finding is reported against a proposed or a staged symbol, and for Linux parent executable resolution. Promote the change-source literals to constants so the new assertions do not trip goconst. --- .../policy_tool_parent_linux_test.go | 39 +++ go/internal/evaluators/cel_expression.go | 10 +- .../cel_growing_symbol_internal_test.go | 231 ++++++++++++++++++ .../managedcapture/sandbox_cache_env.go | 12 +- .../managedcapture/sandbox_cache_env_test.go | 52 ++++ 5 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 go/cmd/coding-ethos-run/policy_tool_parent_linux_test.go create mode 100644 go/internal/evaluators/cel_growing_symbol_internal_test.go diff --git a/go/cmd/coding-ethos-run/policy_tool_parent_linux_test.go b/go/cmd/coding-ethos-run/policy_tool_parent_linux_test.go new file mode 100644 index 00000000..5d26afdd --- /dev/null +++ b/go/cmd/coding-ethos-run/policy_tool_parent_linux_test.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// The actionlint ShellCheck protocol only trusts a parent it can name, so +// resolution must return the real executable behind the parent process. +func TestPolicyToolParentExecutableResolvesRunningParent(t *testing.T) { + t.Parallel() + + parentExecutable, err := policyToolParentExecutable() + if err != nil { + t.Fatalf("resolve parent executable: %v", err) + } + + if !filepath.IsAbs(parentExecutable) { + t.Fatalf("parent executable %q is not an absolute path", parentExecutable) + } + + expected, err := os.Readlink( + filepath.Join("/proc", strconv.Itoa(os.Getppid()), "exe"), + ) + if err != nil { + t.Fatalf("read parent executable link: %v", err) + } + + if parentExecutable != expected { + t.Fatalf("parent executable = %q, want %q", parentExecutable, expected) + } +} diff --git a/go/internal/evaluators/cel_expression.go b/go/internal/evaluators/cel_expression.go index f96e9bdf..aa1b4958 100644 --- a/go/internal/evaluators/cel_expression.go +++ b/go/internal/evaluators/cel_expression.go @@ -25,6 +25,8 @@ type ( const ( bashExtension = ".bash" + changeSourceProposed = "proposed" + changeSourceStaged = "staged" defaultGoHardLineLimit = 2000 defaultPythonHardLineLimit = 1000 defaultShellHardLineLimit = 500 @@ -511,7 +513,7 @@ func applyGrowingSymbolDiagnostic( diagnostic.File = symbol.File diagnostic.Line = int(symbol.ProposedStartLine) diagnostic.Metadata["ast_action"] = symbol.Action - diagnostic.Metadata["ast_change_source"] = "proposed" + diagnostic.Metadata["ast_change_source"] = changeSourceProposed diagnostic.Metadata["ast_language"] = symbol.Language diagnostic.Metadata["ast_line_delta"] = symbol.LineDelta diagnostic.Metadata["ast_nonblank_line_delta"] = symbol.NonBlankLineDelta @@ -531,7 +533,7 @@ func applyGrowingSymbolDiagnostic( diagnostic.File = symbol.File diagnostic.Line = int(symbol.CurrentStartLine) diagnostic.Metadata["ast_action"] = symbol.Action - diagnostic.Metadata["ast_change_source"] = "staged" + diagnostic.Metadata["ast_change_source"] = changeSourceStaged diagnostic.Metadata["ast_language"] = symbol.Language diagnostic.Metadata["ast_line_delta"] = symbol.LineDelta diagnostic.Metadata["ast_nonblank_line_delta"] = symbol.NonBlankLineDelta @@ -571,7 +573,7 @@ func applyLineLimitFileDiagnostic( if file, ok := firstLineLimitProposedFile(activation, thresholds); ok { diagnostic.File = file.File - diagnostic.Metadata["line_limit_change_source"] = "proposed" + diagnostic.Metadata["line_limit_change_source"] = changeSourceProposed diagnostic.Metadata["current_line_count"] = file.CurrentLineCount diagnostic.Metadata["current_nonblank_line_count"] = file.CurrentNonBlankLineCount diagnostic.Metadata["proposed_line_count"] = file.ProposedLineCount @@ -582,7 +584,7 @@ func applyLineLimitFileDiagnostic( if file, ok := firstLineLimitChangedFile(activation, thresholds); ok { diagnostic.File = file.File - diagnostic.Metadata["line_limit_change_source"] = "staged" + diagnostic.Metadata["line_limit_change_source"] = changeSourceStaged diagnostic.Metadata["current_line_count"] = file.LineCount diagnostic.Metadata["current_nonblank_line_count"] = file.NonBlankLineCount diagnostic.Metadata["original_line_count"] = file.OriginalLineCount diff --git a/go/internal/evaluators/cel_growing_symbol_internal_test.go b/go/internal/evaluators/cel_growing_symbol_internal_test.go new file mode 100644 index 00000000..0d5d7b23 --- /dev/null +++ b/go/internal/evaluators/cel_growing_symbol_internal_test.go @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package evaluators + +import ( + "testing" + + "blackcat.ca/coding-ethos/go/diagnostics" + "blackcat.ca/coding-ethos/go/internal/celexpr" +) + +func growingProposedSymbol() celexpr.ProposedSymbolChangeInput { + return celexpr.ProposedSymbolChangeInput{ + Action: "modify", + File: "go/internal/example/widget.go", + Language: "go", + LineCountGrows: true, + LineDelta: 12, + NodeKind: "function_declaration", + NonBlankLineDelta: 9, + SymbolKind: "function", + SymbolName: "Widget", + SymbolPath: "example.Widget", + CurrentLineCount: 20, + CurrentNonBlankLineCount: 16, + ProposedLineCount: 32, + ProposedNonBlankLineCount: 25, + ProposedStartLine: 41, + } +} + +func growingChangedSymbol() celexpr.ChangedSymbolInput { + return celexpr.ChangedSymbolInput{ + Action: "modify", + File: "go/internal/example/gadget.go", + Language: "go", + LineCountGrows: true, + LineDelta: 7, + NodeKind: "method_declaration", + NonBlankLineDelta: 5, + SymbolKind: "method", + SymbolName: "Gadget", + SymbolPath: "example.Gadget", + CurrentLineCount: 30, + CurrentNonBlankLineCount: 24, + CurrentStartLine: 77, + OriginalLineCount: 23, + OriginalNonBlankLineCount: 19, + } +} + +func newGrowingSymbolDiagnostic() *diagnostics.Diagnostic { + return &diagnostics.Diagnostic{Metadata: map[string]any{}} +} + +// A proposed change is the edit the agent is about to make, so it must win +// over the already-staged view of the same repository. +func TestApplyGrowingSymbolDiagnosticPrefersProposedOverStaged(t *testing.T) { + t.Parallel() + + diagnostic := newGrowingSymbolDiagnostic() + applyGrowingSymbolDiagnostic(diagnostic, map[string]any{ + "proposed_symbol_changes": proposedSymbolChangeInputs{ + growingProposedSymbol(), + }, + "changed_symbols": []celexpr.ChangedSymbolInput{growingChangedSymbol()}, + }) + + if diagnostic.File != "go/internal/example/widget.go" { + t.Fatalf("diagnostic file = %q", diagnostic.File) + } + if diagnostic.Line != 41 { + t.Fatalf("diagnostic line = %d, want the proposed start line 41", diagnostic.Line) + } + if diagnostic.Metadata["ast_change_source"] != changeSourceProposed { + t.Fatalf("ast_change_source = %v", diagnostic.Metadata["ast_change_source"]) + } + if diagnostic.Metadata["proposed_line_count"] != int64(32) { + t.Fatalf("proposed_line_count = %v", diagnostic.Metadata["proposed_line_count"]) + } + if diagnostic.Metadata["ast_symbol_path"] != "example.Widget" { + t.Fatalf("ast_symbol_path = %v", diagnostic.Metadata["ast_symbol_path"]) + } + if _, reported := diagnostic.Metadata["original_line_count"]; reported { + t.Fatalf("proposed branch reported staged-only metadata: %#v", diagnostic.Metadata) + } +} + +// Pre-commit runs have no proposed edit, so the staged symbol carries the +// diagnostic and reports the original counts instead of proposed ones. +func TestApplyGrowingSymbolDiagnosticFallsBackToStagedSymbol(t *testing.T) { + t.Parallel() + + diagnostic := newGrowingSymbolDiagnostic() + applyGrowingSymbolDiagnostic(diagnostic, map[string]any{ + "changed_symbols": []celexpr.ChangedSymbolInput{growingChangedSymbol()}, + }) + + if diagnostic.File != "go/internal/example/gadget.go" { + t.Fatalf("diagnostic file = %q", diagnostic.File) + } + if diagnostic.Line != 77 { + t.Fatalf("diagnostic line = %d, want the current start line 77", diagnostic.Line) + } + if diagnostic.Metadata["ast_change_source"] != changeSourceStaged { + t.Fatalf("ast_change_source = %v", diagnostic.Metadata["ast_change_source"]) + } + if diagnostic.Metadata["original_nonblank_line_count"] != int64(19) { + t.Fatalf( + "original_nonblank_line_count = %v", + diagnostic.Metadata["original_nonblank_line_count"], + ) + } + if _, reported := diagnostic.Metadata["proposed_line_count"]; reported { + t.Fatalf("staged branch reported proposed-only metadata: %#v", diagnostic.Metadata) + } +} + +// Symbols that shrink or stay the same size must not be annotated at all. +func TestApplyGrowingSymbolDiagnosticIgnoresNonGrowingSymbols(t *testing.T) { + t.Parallel() + + shrinkingProposed := growingProposedSymbol() + shrinkingProposed.LineCountGrows = false + shrinkingChanged := growingChangedSymbol() + shrinkingChanged.LineCountGrows = false + + diagnostic := newGrowingSymbolDiagnostic() + applyGrowingSymbolDiagnostic(diagnostic, map[string]any{ + "proposed_symbol_changes": proposedSymbolChangeInputs{shrinkingProposed}, + "changed_symbols": []celexpr.ChangedSymbolInput{shrinkingChanged}, + }) + + if diagnostic.File != "" || diagnostic.Line != 0 { + t.Fatalf( + "non-growing symbols moved the diagnostic to %s:%d", + diagnostic.File, + diagnostic.Line, + ) + } + if len(diagnostic.Metadata) != 0 { + t.Fatalf("non-growing symbols emitted metadata: %#v", diagnostic.Metadata) + } +} + +// An activation without AST facts must leave the diagnostic untouched rather +// than panicking on a missing or mistyped key. +func TestApplyGrowingSymbolDiagnosticToleratesMissingSymbolFacts(t *testing.T) { + t.Parallel() + + diagnostic := newGrowingSymbolDiagnostic() + applyGrowingSymbolDiagnostic(diagnostic, map[string]any{ + "proposed_symbol_changes": "not-symbol-inputs", + "changed_symbols": 42, + }) + + if diagnostic.File != "" || len(diagnostic.Metadata) != 0 { + t.Fatalf("mistyped activation produced %#v", diagnostic) + } +} + +func TestFirstGrowingProposedSymbolSkipsNonGrowingEntries(t *testing.T) { + t.Parallel() + + stable := growingProposedSymbol() + stable.LineCountGrows = false + stable.SymbolName = "Stable" + + growing := growingProposedSymbol() + + symbol, found := firstGrowingProposedSymbol(map[string]any{ + "proposed_symbol_changes": proposedSymbolChangeInputs{stable, growing}, + }) + if !found { + t.Fatal("growing proposed symbol not found") + } + if symbol.SymbolName != "Widget" { + t.Fatalf("symbol name = %q, want the growing entry", symbol.SymbolName) + } + + _, found = firstGrowingProposedSymbol(map[string]any{ + "proposed_symbol_changes": proposedSymbolChangeInputs{stable}, + }) + if found { + t.Fatal("non-growing proposed symbols reported a growth") + } +} + +func TestFirstGrowingChangedSymbolSkipsNonGrowingEntries(t *testing.T) { + t.Parallel() + + stable := growingChangedSymbol() + stable.LineCountGrows = false + stable.SymbolName = "Stable" + + growing := growingChangedSymbol() + + symbol, found := firstGrowingChangedSymbol(map[string]any{ + "changed_symbols": []celexpr.ChangedSymbolInput{stable, growing}, + }) + if !found { + t.Fatal("growing changed symbol not found") + } + if symbol.SymbolName != "Gadget" { + t.Fatalf("symbol name = %q, want the growing entry", symbol.SymbolName) + } + + _, found = firstGrowingChangedSymbol(map[string]any{"changed_symbols": nil}) + if found { + t.Fatal("absent changed symbols reported a growth") + } +} + +func TestProposedSymbolChangesRequiresTypedInputs(t *testing.T) { + t.Parallel() + + symbols, found := proposedSymbolChanges(map[string]any{ + "proposed_symbol_changes": proposedSymbolChangeInputs{growingProposedSymbol()}, + }) + if !found || len(symbols) != 1 { + t.Fatalf("typed activation returned %d symbols (found=%t)", len(symbols), found) + } + + _, found = proposedSymbolChanges(map[string]any{ + "proposed_symbol_changes": []string{"widget"}, + }) + if found { + t.Fatal("untyped activation value was accepted") + } +} diff --git a/go/internal/managedcapture/sandbox_cache_env.go b/go/internal/managedcapture/sandbox_cache_env.go index bbd0689a..5500ca08 100644 --- a/go/internal/managedcapture/sandbox_cache_env.go +++ b/go/internal/managedcapture/sandbox_cache_env.go @@ -242,8 +242,16 @@ func sandboxUVProjectEnvironment(root string, request captureRequest) (string, e return "", fmt.Errorf("resolve uv project path %s: %w", project, err) } - canonical, err = filepath.EvalSymlinks(canonical) - if err != nil { + // A consumer repo may name a uv project that does not exist yet. Fall back + // to the lexical path so the sandbox still derives a stable environment + // instead of denying the capture outright. + resolved, err := filepath.EvalSymlinks(canonical) + switch { + case err == nil: + canonical = resolved + case os.IsNotExist(err): + canonical = filepath.Clean(canonical) + default: return "", fmt.Errorf("resolve uv project symlinks %s: %w", project, err) } diff --git a/go/internal/managedcapture/sandbox_cache_env_test.go b/go/internal/managedcapture/sandbox_cache_env_test.go index dd510144..11d42d06 100644 --- a/go/internal/managedcapture/sandbox_cache_env_test.go +++ b/go/internal/managedcapture/sandbox_cache_env_test.go @@ -133,3 +133,55 @@ func TestSandboxCacheEnvIsolatesUVProjectUnderConsumerCache(t *testing.T) { t.Fatalf("captured process omitted UV project environment") } + +// A consumer repo can name a uv project directory that has not been installed +// yet. Resolution must fall back to the lexical path instead of denying the +// capture, which is what made managed capture fail from a consumer root. +func TestSandboxUVProjectEnvironmentAcceptsMissingProjectDirectory(t *testing.T) { + consumerRoot := t.TempDir() + missingProject := filepath.Join(t.TempDir(), "pre-commit", "hooks") + + environment, err := sandboxUVProjectEnvironment(consumerRoot, captureRequest{ + UVProject: missingProject, + }) + if err != nil { + t.Fatalf("sandboxUVProjectEnvironment for missing project: %v", err) + } + + digest := sha256.Sum256([]byte(filepath.Clean(missingProject))) + want := filepath.Join( + consumerRoot, + ".coding-ethos", + "cache", + "uv-project-env", + hex.EncodeToString(digest[:]), + ) + if environment != want { + t.Fatalf("UV project environment = %q, want %q", environment, want) + } + + if info, statErr := os.Stat(want); statErr != nil || !info.IsDir() { + t.Fatalf("UV project environment is not usable: info=%v error=%v", info, statErr) + } +} + +// An empty uv project means the tool is not uv-backed, so no environment is +// derived and no directory is created. +func TestSandboxUVProjectEnvironmentSkipsToolsWithoutUVProject(t *testing.T) { + consumerRoot := t.TempDir() + + environment, err := sandboxUVProjectEnvironment(consumerRoot, captureRequest{ + UVProject: " ", + }) + if err != nil { + t.Fatalf("sandboxUVProjectEnvironment without a project: %v", err) + } + if environment != "" { + t.Fatalf("UV project environment = %q, want empty", environment) + } + + cacheRoot := filepath.Join(consumerRoot, ".coding-ethos", "cache", "uv-project-env") + if _, statErr := os.Stat(cacheRoot); !os.IsNotExist(statErr) { + t.Fatalf("uv project cache root created without a project: %v", statErr) + } +} From 1f312fb754d6750db2ca478f657a73396e60f95f Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Wed, 9 Sep 2026 00:38:13 -0600 Subject: [PATCH 29/30] fix(evaluators): skip shell paths the edit deleted readShellText reported a missing file as empty text with no error, but the caller only skipped binary files. A deleted absolute shell path therefore reached commonShellHelperComparisonPath, whose EvalSymlinks call failed the whole evaluation. Post-edit lint passes the absolute path of every changed file, deletions included, so this aborted the shell policy on any commit that removed a script. Make the skip explicit in readShellText and honour it at the call site. Reading the file as empty and continuing is not sufficient: an empty shell file violates every convention the policy checks, so a deleted script would have been reported for a missing shebang and a missing strict-mode line. --- .../evaluators/shell_best_practices.go | 10 +++-- .../evaluators/shell_best_practices_test.go | 43 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/go/internal/evaluators/shell_best_practices.go b/go/internal/evaluators/shell_best_practices.go index f2d95a88..99058d8e 100644 --- a/go/internal/evaluators/shell_best_practices.go +++ b/go/internal/evaluators/shell_best_practices.go @@ -57,12 +57,12 @@ func EvaluateShellBestPractices( } for _, file := range shellFiles { - text, binary, err := readShellText(file) + text, skip, err := readShellText(file) if err != nil { return nil, err } - if binary { + if skip { continue } @@ -293,11 +293,15 @@ func looksLikeShellFile(path string) bool { } } +// readShellText returns the file's text and a skip flag. Skip is true when the +// file must not be evaluated at all: a binary file, or a path deleted by the +// edit under review. A deleted path must be skipped rather than reported as an +// empty file that violates every shell convention. func readShellText(path string) (string, bool, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return "", false, nil + return "", true, nil } return "", false, fmt.Errorf("read shell file %s: %w", path, err) diff --git a/go/internal/evaluators/shell_best_practices_test.go b/go/internal/evaluators/shell_best_practices_test.go index f6f92a27..6462291a 100644 --- a/go/internal/evaluators/shell_best_practices_test.go +++ b/go/internal/evaluators/shell_best_practices_test.go @@ -273,6 +273,49 @@ func TestEvaluateShellBestPracticesRequiresOnlyExistingCommonHelper(t *testing.T } } +// Post-edit lint passes the absolute paths of every changed file, including +// files the edit deleted. A deleted path must be skipped outright: it must not +// fail path normalization, and it must not be reported as an empty file that +// violates every shell convention. +func TestEvaluateShellBestPracticesSkipsDeletedAbsoluteShellPaths(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + initializeStagedAdminGitRepo(t, repo) + scriptsDir := filepath.Join(repo, "scripts") + if err := os.MkdirAll(scriptsDir, 0o700); err != nil { + t.Fatalf("create scripts directory: %v", err) + } + + content := []byte("#!/usr/bin/env bash\nset -euo pipefail\necho ok\n") + commonPath := filepath.Join(scriptsDir, "common.sh") + if err := os.WriteFile(commonPath, content, 0o600); err != nil { + t.Fatalf("write common helper: %v", err) + } + runGit(t, repo, "add", "scripts/common.sh") + + deletedPath := filepath.Join(scriptsDir, "removed.sh") + + decisions, err := EvaluateShellBestPractices( + shellBestPracticesPolicy(), + Context{ + Cwd: repo, + Files: []string{deletedPath}, + EvaluatorOptions: map[string]any{ + "require_common_for_prefixes": []any{ + scriptsDir + string(filepath.Separator), + }, + }, + }, + ) + if err != nil { + t.Fatalf("deleted shell path failed evaluation: %v", err) + } + if len(decisions) != 0 { + t.Fatalf("deleted shell path produced decisions: %#v", decisions) + } +} + func TestEvaluateShellBestPracticesResolvesCommonHelperFromNestedCWD( t *testing.T, ) { From fb0a79da23b78bc0d80b6b0bf9f77f1601e09624 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Wed, 9 Sep 2026 00:45:38 -0600 Subject: [PATCH 30/30] fix(e2e): resolve taskkill from SystemRoot instead of PATH exec.CommandContext resolves a bare executable name through PATH. A PATH without System32 would fail process-tree cancellation and leave the child running, so build the path from SystemRoot with a documented fallback. Cover the conditional and background operators in the pipefail execution-order table. The && and || cases exercise the operator alignment that segmentControlsShellState relies on, and the background case confirms that & leaves the inherited shell state untouched. --- go/internal/e2e/process_windows.go | 15 ++++++++++++++- go/internal/hooks/gate_exit_status_test.go | 13 +++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/go/internal/e2e/process_windows.go b/go/internal/e2e/process_windows.go index f88b47cc..3d0aafa7 100644 --- a/go/internal/e2e/process_windows.go +++ b/go/internal/e2e/process_windows.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strconv" "golang.org/x/sys/windows" @@ -42,7 +43,7 @@ func terminateCommandProcessGroup(cmd *exec.Cmd) error { command := exec.CommandContext( terminationContext, - "taskkill.exe", + windowsTaskkillPath(), "/PID", strconv.Itoa(cmd.Process.Pid), "/T", "/F", @@ -58,6 +59,18 @@ func terminateCommandProcessGroup(cmd *exec.Cmd) error { return fmt.Errorf("terminate command process tree %d: %w", cmd.Process.Pid, err) } +// windowsTaskkillPath resolves taskkill.exe from SystemRoot rather than PATH. +// A PATH without System32 would otherwise fail cancellation and leave the +// process tree running. +func windowsTaskkillPath() string { + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = `C:\Windows` + } + + return filepath.Join(systemRoot, "System32", "taskkill.exe") +} + func windowsProcessExited(pid int) bool { handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { diff --git a/go/internal/hooks/gate_exit_status_test.go b/go/internal/hooks/gate_exit_status_test.go index bc52215f..65e27d84 100644 --- a/go/internal/hooks/gate_exit_status_test.go +++ b/go/internal/hooks/gate_exit_status_test.go @@ -78,6 +78,19 @@ func TestRequiredGateExitStatusTracksPipefailInExecutionOrder(t *testing.T) { command: "set +o pipefail | make check | tail -1", inherited: true, }, + { + name: "conditional enable before gate", + command: "set -o pipefail && make check | tail -1", + }, + { + name: "conditional enable after false branch", + command: "false || set -o pipefail; make check | tail -1", + }, + { + name: "background segment does not change shell state", + command: "set +o pipefail & make check | tail -1", + inherited: true, + }, { name: "disable then enable", command: "set +o pipefail; set -o pipefail; make check | tail -1",