diff --git a/README.md b/README.md index 410752dd..95bdc1fc 100644 --- a/README.md +++ b/README.md @@ -670,6 +670,13 @@ does not block the tool result; the hook emits a concise refresh hint with `coding-ethos-code-intel rebuild-index`. Repositories can disable or cap this context with `proxy.code_intel_enrichment` in `repo_config.yaml`. +PreToolUse hooks also perform narrow semantic policy injection. Mutating Git +intents such as `policy-git commit` inject the `safe-git-workflow` skill pointer +and policy-git reminder only for that turn. File-target tool calls that name +Python files inject compact Python static-analysis guidance and the relevant +skill pointer instead of front-loading every Python policy into startup context. +Read-only Git inspection commands stay quiet. + Provider-native file read tools are the supported path for reading source. Claude-style Bash file-tool emulation such as `cat `, `sed -n '1,20p' `, `awk ... `, `tee `, and `echo`/`printf` diff --git a/docs/AGENT_PROXY.md b/docs/AGENT_PROXY.md index e8354dfd..6f2f7349 100644 --- a/docs/AGENT_PROXY.md +++ b/docs/AGENT_PROXY.md @@ -359,6 +359,25 @@ pagination or cached-read transforms at the provider file-read boundary, but it must record `file_read` and `cache_hit` events in the provider-neutral proxy ledger rather than inferring file reads from shell output. +## Semantic Policy Injection + +The first just-in-time policy injection slice runs in the local hook path at +`PreToolUse`. It does not load every skill or policy into startup context. +Instead, it emits compact, exact pointers only when the incoming tool call shows +matching intent: + +- mutating Git commands, including managed `policy-git commit`, receive the + `safe-git-workflow` skill pointer and policy-git reminder; +- file-target tool calls that name Python files receive compact Python + static-analysis guidance and the relevant Python skill pointer; +- read-only Git inspection commands stay quiet. + +This is intentionally rule-based, not vector retrieval. The hook output records +the trigger, reason, skill id, policy scope, and next action so the injected +context is auditable and small. Provider adapters may still suppress pre-tool +advice where a provider does not support allowed `PreToolUse` context; those +capability limits are documented in `docs/PROVIDER_CAPABILITY_MATRIX.md`. + ## Startup Repo Map On `SessionStart`, the hook runtime refreshes the repo-local Tree-sitter index diff --git a/go/internal/hookrunnercli/toolchain_groups.go b/go/internal/hookrunnercli/toolchain_groups.go index 14c1c887..490e0daf 100644 --- a/go/internal/hookrunnercli/toolchain_groups.go +++ b/go/internal/hookrunnercli/toolchain_groups.go @@ -212,12 +212,7 @@ func runGoVet(_ Config, paths []string) int { return 0 } - worktree, ok := configuredGoWorktreeName() - if !ok { - return 1 - } - - return runManagedPolicyTool("go-vet", []string{worktree}) + return runManagedPolicyTool("go-vet", nil) } func runGoTests(_ Config, paths []string) int { @@ -225,12 +220,7 @@ func runGoTests(_ Config, paths []string) int { return 0 } - worktree, ok := configuredGoWorktreeName() - if !ok { - return 1 - } - - return runManagedPolicyTool(goTestToolName, []string{worktree}) + return runManagedPolicyTool(goTestToolName, nil) } func runGoCoverageThreshold(cfg Config, paths []string) int { @@ -760,18 +750,11 @@ func runGolangciLint(cfg Config, paths []string) int { return 0 } - worktree, ok := configuredGoWorktreeName() - if !ok { - return 1 - } - - args := []string{} + args := []string{"run"} if cfg.HookStage == hookStagePreCommit { args = append(args, "--new-from-rev=HEAD") } - args = append(args, worktree) - return runManagedPolicyTool("golangci-lint", args) } @@ -857,31 +840,6 @@ func openPolicyBundleFile(path string) (*os.File, error) { return file, nil } -func configuredGoWorktreeName() (string, bool) { - _, _, rootConfig, err := loadBundleConsumerAndConfig() - if err != nil { - writef(os.Stderr, "FATAL: %v\n", err) - - return "", false - } - - worktree := configuredGoWorktreeValue(rootConfig) - path := repoPath(worktree) - - info, err := os.Stat(path) - if err != nil || !info.IsDir() { - fmt.Fprintf( - os.Stderr, - "FATAL: go.worktree is set to %q, but that directory does not exist\n", - worktree, - ) - - return "", false - } - - return worktree, true -} - func configuredGoWorktree() (string, bool) { _, _, rootConfig, err := loadBundleConsumerAndConfig() if err != nil { diff --git a/go/internal/hooks/git_wrapper_enforcement.go b/go/internal/hooks/git_wrapper_enforcement.go index 215c7cae..229a0cb1 100644 --- a/go/internal/hooks/git_wrapper_enforcement.go +++ b/go/internal/hooks/git_wrapper_enforcement.go @@ -152,7 +152,7 @@ func textRequestsGitWork(text string) bool { normalized := strings.ToLower(text) if strings.Contains(normalized, "commit") || - strings.Contains(normalized, "push") || + strings.Contains(normalized, operationPush) || strings.Contains(normalized, "amend") || strings.Contains(normalized, "merge") || strings.Contains(normalized, "rebase") || @@ -600,7 +600,7 @@ func managedGitSegment(segment []string) bool { command := segment[0] commandBase := filepath.Base(command) - if commandBase == "coding-ethos-run" { + if commandBase == wrapperRunnerName { return isTrustedRunnerCommand(command) && len(segment) > 1 && segment[1] == "policy-git" diff --git a/go/internal/hooks/runner.go b/go/internal/hooks/runner.go index fd99b1b3..f56ad821 100644 --- a/go/internal/hooks/runner.go +++ b/go/internal/hooks/runner.go @@ -34,6 +34,7 @@ const ( modeRecord = "record" statusAllowed = "allowed" statusBlocked = "blocked" + operationPush = "push" slowHookBudgetMS = int64(2500) hookDividerWidth = 50 ) @@ -290,11 +291,13 @@ func hookSpecificOutput( }, nil } - if output := advisoryHookOutput(event); output != nil { - return output, nil - } - - if output := postEditOutput(bundle, event); output != nil { + if output := mergeHookSpecificOutputs( + semanticPolicyInjectionOutput(bundle, event), + sessionMemoryImportOutput(event), + continuationOutput(event), + lifecycleOutput(event), + postEditOutput(bundle, event), + ); output != nil { return output, nil } @@ -311,14 +314,6 @@ func hookSpecificOutput( return postToolBashOutput(bundle, event) } -func advisoryHookOutput(event Event) *HookSpecificOutput { - return mergeHookSpecificOutputs( - lifecycleOutput(event), - continuationOutput(event), - sessionMemoryImportOutput(event), - ) -} - func mergeHookSpecificOutputs(outputs ...*HookSpecificOutput) *HookSpecificOutput { var merged *HookSpecificOutput @@ -566,7 +561,7 @@ func hookOperation(command string) string { operation := "commit" if strings.Contains(strings.ToLower(command), "git push") { - operation = "push" + operation = operationPush } if strings.Contains(strings.ToLower(command), "pre-commit") { @@ -591,7 +586,7 @@ func buildHookOutputContextHuman( reminders []renderedEthosReminder, ) string { hookType := "PRE-COMMIT" - if operation == "push" { + if operation == operationPush { hookType = "PRE-PUSH" } diff --git a/go/internal/hooks/runner_test.go b/go/internal/hooks/runner_test.go index 7ca4daa3..cfd11c4b 100644 --- a/go/internal/hooks/runner_test.go +++ b/go/internal/hooks/runner_test.go @@ -4,6 +4,7 @@ package hooks_test import ( + "bytes" "context" "encoding/json" "fmt" @@ -4499,6 +4500,197 @@ func TestRunSurfacesContinuationCaptureFailure(t *testing.T) { } } +func TestRunInjectsSemanticPolicyForGitMutation(t *testing.T) { + t.Parallel() + + result, err := Run(policy.ExampleBundle(), Options{ + Event: Event{ + HookEventName: eventPreToolUse, + ToolName: toolBash, + ToolInput: map[string]any{ + "command": "bin/coding-ethos-run policy-git commit -m test", + }, + }, + }) + if err != nil { + t.Fatalf("run hook: %v", err) + } + if result.Status != statusAllowed || result.HookSpecificOutput == nil { + t.Fatalf("missing semantic policy context: %#v", result) + } + + context := result.HookSpecificOutput.AdditionalContext + for _, expected := range []string{ + "semantic_policy_injection[1]", + "git_mutation", + "safe-git-workflow", + "bin/coding-ethos-run policy-git", + } { + if !strings.Contains(context, expected) { + t.Fatalf("missing %q in semantic context: %s", expected, context) + } + } +} + +func TestRunInjectsSemanticPolicyForGitMutationWithGlobalOptions(t *testing.T) { + t.Parallel() + + for _, command := range []string{ + "bin/coding-ethos-run policy-git -C /repo commit -m test", + "bin/coding-ethos-run policy-git -c user.name=test commit -m test", + } { + t.Run(command, func(t *testing.T) { + t.Parallel() + + result, err := Run(policy.ExampleBundle(), Options{ + Event: Event{ + HookEventName: eventPreToolUse, + ToolName: toolBash, + ToolInput: map[string]any{ + "command": command, + }, + }, + }) + if err != nil { + t.Fatalf("run hook: %v", err) + } + if result.Status != statusAllowed || result.HookSpecificOutput == nil { + t.Fatalf("missing semantic policy context: %#v", result) + } + + context := result.HookSpecificOutput.AdditionalContext + if !strings.Contains(context, "git_mutation") { + t.Fatalf("missing git mutation context: %s", context) + } + }) + } +} + +func TestRunSkipsSemanticPolicyForReadOnlyGitInspectionWithGlobalOptions( + t *testing.T, +) { + t.Parallel() + + result, err := Run(policy.ExampleBundle(), Options{ + Event: Event{ + HookEventName: eventPreToolUse, + ToolName: toolBash, + ToolInput: map[string]any{ + "command": "bin/coding-ethos-run policy-git -C /repo status --short", + }, + }, + }) + if err != nil { + t.Fatalf("run hook: %v", err) + } + if result.HookSpecificOutput != nil { + t.Fatalf("read-only git inspection should stay quiet: %#v", result) + } +} + +func TestRunSkipsSemanticPolicyForReadOnlyGitInspection(t *testing.T) { + t.Parallel() + + result, err := Run(policy.ExampleBundle(), Options{ + Event: Event{ + HookEventName: eventPreToolUse, + ToolName: toolBash, + ToolInput: map[string]any{ + "command": "bin/coding-ethos-run policy-git status --short", + }, + }, + }) + if err != nil { + t.Fatalf("run hook: %v", err) + } + if result.HookSpecificOutput != nil { + t.Fatalf("read-only git inspection should stay quiet: %#v", result) + } +} + +func TestRunInjectsSemanticPolicyForPythonFileTarget(t *testing.T) { + t.Parallel() + + result, err := Run(policy.ExampleBundle(), Options{ + Event: Event{ + HookEventName: eventPreToolUse, + ToolName: "Read", + ToolInput: map[string]any{ + "file_path": "pkg/app.py", + }, + }, + }) + if err != nil { + t.Fatalf("run hook: %v", err) + } + if result.Status != statusAllowed || result.HookSpecificOutput == nil { + t.Fatalf("missing semantic policy context: %#v", result) + } + + context := result.HookSpecificOutput.AdditionalContext + for _, expected := range []string{ + "semantic_policy_injection[1]", + "python_file", + "conditional-imports", + "ruff/mypy", + } { + if !strings.Contains(context, expected) { + t.Fatalf("missing %q in semantic context: %s", expected, context) + } + } +} + +func TestEncodeGeminiSemanticPolicyInjection(t *testing.T) { + t.Parallel() + + result, err := Run(policy.ExampleBundle(), Options{ + Event: Event{ + HookEventName: eventPreToolUse, + ProviderHint: "gemini", + ToolName: "Read", + ToolInput: map[string]any{ + "file_path": "pkg/app.py", + }, + }, + }) + if err != nil { + t.Fatalf("run hook: %v", err) + } + + var output bytes.Buffer + err = EncodeProviderResult(&output, result) + if err != nil { + t.Fatalf("encode provider result: %v", err) + } + + encoded := output.String() + + var decoded struct { + Decision string `json:"decision"` + HookSpecificOutput struct { + AdditionalContext string `json:"additionalContext"` + } `json:"hookSpecificOutput"` + SystemMessage string `json:"systemMessage"` + } + if err := json.Unmarshal([]byte(encoded), &decoded); err != nil { + t.Fatalf("encoded output is not valid JSON: %v\n%s", err, encoded) + } + + if decoded.Decision != permissionAllow { + t.Fatalf("decision = %q, want %q", decoded.Decision, permissionAllow) + } + if decoded.SystemMessage != "coding-ethos added hook context for this turn." { + t.Fatalf("unexpected system message: %q", decoded.SystemMessage) + } + + context := decoded.HookSpecificOutput.AdditionalContext + for _, expected := range []string{"semantic_policy_injection", "python_file"} { + if !strings.Contains(context, expected) { + t.Fatalf("missing %q in encoded context: %s", expected, context) + } + } +} + func TestRunAddsUserPromptGuidance(t *testing.T) { t.Parallel() diff --git a/go/internal/hooks/semantic_policy_injection.go b/go/internal/hooks/semantic_policy_injection.go new file mode 100644 index 00000000..a1e557ac --- /dev/null +++ b/go/internal/hooks/semantic_policy_injection.go @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package hooks + +import ( + "path/filepath" + "slices" + "strconv" + "strings" + + "blackcat.ca/coding-ethos/go/internal/policy" + "blackcat.ca/coding-ethos/go/internal/shellparse" +) + +const semanticPolicyInjectionHeaderLines = 2 + +type semanticPolicyInjection struct { + Trigger string + Reason string + SkillID string + PolicyScope string + Next string +} + +func semanticPolicyInjectionOutput( + bundle policy.Bundle, + event Event, +) *HookSpecificOutput { + if event.HookEventName != eventPreToolUse { + return nil + } + + injections := semanticPolicyInjections(bundle, event) + if len(injections) == 0 { + return nil + } + + return &HookSpecificOutput{ + HookEventName: event.HookEventName, + AdditionalContext: renderSemanticPolicyInjections(injections), + } +} + +func semanticPolicyInjections( + bundle policy.Bundle, + event Event, +) []semanticPolicyInjection { + injections := []semanticPolicyInjection{} + + if semanticGitMutationIntent(event) { + injections = append(injections, semanticPolicyInjection{ + Trigger: "git_mutation", + Reason: "mutating Git intent detected in the incoming tool call", + SkillID: semanticSkillID(bundle, "safe-git-workflow"), + PolicyScope: "git", + Next: "Load the safe-git-workflow skill before changing Git state; " + + "use " + wrapperRunnerPath + " policy-git and keep hook output visible.", + }) + } + + if semanticPythonFileIntent(event) { + injections = append(injections, semanticPolicyInjection{ + Trigger: "python_file", + Reason: "Python file target detected in the incoming tool call", + SkillID: semanticSkillID(bundle, "conditional-imports", "lint-remediation"), + PolicyScope: "python", + Next: "Apply Python static-analysis policy before editing or reasoning; " + + "prefer ruff/mypy evidence and load conditional-imports for import-cycle findings.", + }) + } + + return injections +} + +func semanticSkillID(bundle policy.Bundle, candidates ...string) string { + for _, candidate := range candidates { + if _, ok := bundle.Skills[candidate]; ok { + return candidate + } + } + + if len(candidates) == 0 { + return "" + } + + return candidates[0] +} + +func semanticGitMutationIntent(event Event) bool { + if event.ToolName != toolBash { + return false + } + + commands, err := shellparse.Commands(event.Command()) + if err != nil { + return false + } + + return slices.ContainsFunc(commands, shellCommandMutatesGit) +} + +func shellCommandMutatesGit(command shellparse.Command) bool { + args := command.Argv + if len(args) == 0 { + return false + } + + switch shellCommandName(command) { + case tokenGit: + return semanticGitMutation(semanticGitSubcommand(args[1:])) + case wrapperRunnerName: + return len(args) > 2 && + args[1] == "policy-git" && + semanticGitMutation(semanticGitSubcommand(args[2:])) + case "coding-ethos-git": + return semanticGitMutation(semanticGitSubcommand(args[1:])) + } + + return false +} + +func semanticGitSubcommand(args []string) string { + for index := 0; index < len(args); index++ { + arg := strings.TrimSpace(args[index]) + if arg == "" { + continue + } + + if arg == "--" { + return "" + } + + if !strings.HasPrefix(arg, "-") { + return arg + } + + if semanticGitGlobalOptionConsumesNext(arg) { + index++ + } + } + + return "" +} + +func semanticGitGlobalOptionConsumesNext(option string) bool { + if strings.Contains(option, "=") { + return false + } + + switch option { + case "-C", + "-c", + "--config-env", + "--exec-path", + "--git-dir", + "--namespace", + "--work-tree": + return true + default: + return false + } +} + +func semanticGitMutation(operation string) bool { + operation = strings.TrimSpace(operation) + if operation == "" || strings.HasPrefix(operation, "-") { + return false + } + + switch operation { + case "add", + "am", + "apply", + "branch", + "checkout", + "cherry-pick", + "clean", + "commit", + "merge", + "mv", + "pull", + operationPush, + "rebase", + "reset", + "restore", + "revert", + "rm", + "stash", + "switch", + "tag", + "worktree": + return true + default: + return false + } +} + +func semanticPythonFileIntent(event Event) bool { + for _, file := range event.Files() { + if strings.EqualFold(filepath.Ext(file), ".py") { + return true + } + } + + return false +} + +func renderSemanticPolicyInjections( + injections []semanticPolicyInjection, +) string { + lines := make([]string, 0, semanticPolicyInjectionHeaderLines+len(injections)) + lines = append( + lines, + "event: PreToolUse", + "semantic_policy_injection["+strconv.Itoa(len(injections))+ + "]{trigger,reason,skill_id,policy_scope,next}:", + ) + + for _, injection := range injections { + lines = append( + lines, + " "+toonCell(injection.Trigger)+","+ + toonCell(injection.Reason)+","+ + toonCell(injection.SkillID)+","+ + toonCell(injection.PolicyScope)+","+ + toonCell(injection.Next), + ) + } + + return strings.Join(lines, "\n") +} diff --git a/go/internal/hooks/semantic_policy_injection_test.go b/go/internal/hooks/semantic_policy_injection_test.go new file mode 100644 index 00000000..fe6edc9f --- /dev/null +++ b/go/internal/hooks/semantic_policy_injection_test.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package hooks + +import ( + "testing" + + "blackcat.ca/coding-ethos/go/internal/shellparse" +) + +func TestShellCommandMutatesGitSkipsGlobalOptions(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + command string + want bool + }{ + { + name: "git -C commit", + command: "git -C /repo commit -m test", + want: true, + }, + { + name: "git -c commit", + command: "git -c user.name=test commit -m test", + want: true, + }, + { + name: "policy-git -C commit", + command: "bin/coding-ethos-run policy-git -C /repo commit -m test", + want: true, + }, + { + name: "coding-ethos-git -C commit", + command: "coding-ethos-git -C /repo commit -m test", + want: true, + }, + { + name: "git -C status", + command: "git -C /repo status --short", + want: false, + }, + { + name: "git -c alias value status", + command: "git -c alias.status=commit status --short", + want: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + commands, err := shellparse.Commands(tc.command) + if err != nil { + t.Fatalf("parse command: %v", err) + } + if len(commands) != 1 { + t.Fatalf("commands = %#v, want one command", commands) + } + + if got := shellCommandMutatesGit(commands[0]); got != tc.want { + t.Fatalf("shellCommandMutatesGit() = %t, want %t", got, tc.want) + } + }) + } +} diff --git a/go/internal/mcp/server_test.go b/go/internal/mcp/server_test.go index 894c1222..95ca6f4e 100644 --- a/go/internal/mcp/server_test.go +++ b/go/internal/mcp/server_test.go @@ -369,7 +369,7 @@ func seedModernWebSearchCache(t *testing.T) string { t.Helper() root := t.TempDir() - now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC() _, err := webguidance.Adapter{ Root: root, Runner: modernWebFakeRunner{}, diff --git a/go/internal/webguidancecli/main_internal_test.go b/go/internal/webguidancecli/main_internal_test.go index fccc2b17..0d764a05 100644 --- a/go/internal/webguidancecli/main_internal_test.go +++ b/go/internal/webguidancecli/main_internal_test.go @@ -135,7 +135,7 @@ func TestParseOptionsReturnsInvalidFlagError(t *testing.T) { func TestRetrieveWritesJSONFromCache(t *testing.T) { root := t.TempDir() - now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC() _, err := webguidance.Adapter{ Root: root, Runner: cliFakeRunner{}, @@ -190,7 +190,7 @@ func seedSearchCache(t *testing.T) string { t.Helper() root := t.TempDir() - now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC() _, err := webguidance.Adapter{ Root: root, Runner: cliFakeRunner{},