From c6db516433bef5ca73b3cb25ea9a4bcfe19a09b5 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 23 Jun 2026 20:41:55 -0600 Subject: [PATCH 1/3] feat(proxy): add semantic policy injection --- README.md | 7 + docs/AGENT_PROXY.md | 19 ++ go/internal/hooks/git_wrapper_enforcement.go | 4 +- go/internal/hooks/runner.go | 61 ++++-- go/internal/hooks/runner_test.go | 121 +++++++++++ .../hooks/semantic_policy_injection.go | 190 ++++++++++++++++++ 6 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 go/internal/hooks/semantic_policy_injection.go diff --git a/README.md b/README.md index dd698559..8abe1de7 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 842be9aa..af1b902f 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/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 07fe6c68..f52e920a 100644 --- a/go/internal/hooks/runner.go +++ b/go/internal/hooks/runner.go @@ -33,6 +33,7 @@ const ( modeRecord = "record" statusAllowed = "allowed" statusBlocked = "blocked" + operationPush = "push" slowHookBudgetMS = int64(2500) hookDividerWidth = 50 ) @@ -289,19 +290,13 @@ func hookSpecificOutput( }, nil } - if output := sessionMemoryImportOutput(event); output != nil { - return output, nil - } - - if output := continuationOutput(event); output != nil { - return output, nil - } - - if output := lifecycleOutput(event); output != nil { - return output, nil - } - - if output := postEditOutput(bundle, event); output != nil { + if output := mergeAdvisoryHookOutputs( + semanticPolicyInjectionOutput(bundle, event), + sessionMemoryImportOutput(event), + continuationOutput(event), + lifecycleOutput(event), + postEditOutput(bundle, event), + ); output != nil { return output, nil } @@ -318,6 +313,42 @@ func hookSpecificOutput( return postToolBashOutput(bundle, event) } +func mergeAdvisoryHookOutputs(outputs ...*HookSpecificOutput) *HookSpecificOutput { + var merged *HookSpecificOutput + + contexts := []string{} + + for _, output := range outputs { + if output == nil { + continue + } + + if output.PermissionDecision != "" || + output.PermissionDecisionReason != "" || + len(output.UpdatedInput) > 0 { + return output + } + + if merged == nil { + next := *output + next.AdditionalContext = "" + merged = &next + } + + if output.AdditionalContext != "" { + contexts = append(contexts, output.AdditionalContext) + } + } + + if merged == nil { + return nil + } + + merged.AdditionalContext = strings.Join(contexts, "\n") + + return merged +} + func postToolBashOutput( bundle policy.Bundle, event Event, @@ -495,7 +526,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") { @@ -520,7 +551,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 45e84468..4b0e5a4d 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,126 @@ 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 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() + for _, expected := range []string{ + `"decision": "allow"`, + `"systemMessage": "coding-ethos added hook context for this turn."`, + "semantic_policy_injection", + "python_file", + } { + if !strings.Contains(encoded, expected) { + t.Fatalf("missing %q in encoded output: %s", expected, encoded) + } + } +} + 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..b0592d0d --- /dev/null +++ b/go/internal/hooks/semantic_policy_injection.go @@ -0,0 +1,190 @@ +// 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 len(args) > 1 && semanticGitMutation(args[1]) + case wrapperRunnerName: + return len(args) > 2 && + args[1] == "policy-git" && + semanticGitMutation(args[2]) + case "coding-ethos-git": + return len(args) > 1 && semanticGitMutation(args[1]) + } + + 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") +} From 4adf67bea6746eb8028e54e4901cb87ecb5f9506 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Tue, 23 Jun 2026 21:00:00 -0600 Subject: [PATCH 2/3] fix(proxy): handle semantic policy review feedback --- go/internal/hooks/runner.go | 6 +- go/internal/hooks/runner_test.go | 87 +++++++++++++++++-- .../hooks/semantic_policy_injection.go | 48 +++++++++- .../hooks/semantic_policy_injection_test.go | 67 ++++++++++++++ 4 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 go/internal/hooks/semantic_policy_injection_test.go diff --git a/go/internal/hooks/runner.go b/go/internal/hooks/runner.go index f52e920a..d3503141 100644 --- a/go/internal/hooks/runner.go +++ b/go/internal/hooks/runner.go @@ -330,9 +330,9 @@ func mergeAdvisoryHookOutputs(outputs ...*HookSpecificOutput) *HookSpecificOutpu } if merged == nil { - next := *output - next.AdditionalContext = "" - merged = &next + merged = &HookSpecificOutput{ + HookEventName: output.HookEventName, + } } if output.AdditionalContext != "" { diff --git a/go/internal/hooks/runner_test.go b/go/internal/hooks/runner_test.go index 4b0e5a4d..14e912e5 100644 --- a/go/internal/hooks/runner_test.go +++ b/go/internal/hooks/runner_test.go @@ -4532,6 +4532,62 @@ func TestRunInjectsSemanticPolicyForGitMutation(t *testing.T) { } } +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() @@ -4608,14 +4664,29 @@ func TestEncodeGeminiSemanticPolicyInjection(t *testing.T) { } encoded := output.String() - for _, expected := range []string{ - `"decision": "allow"`, - `"systemMessage": "coding-ethos added hook context for this turn."`, - "semantic_policy_injection", - "python_file", - } { - if !strings.Contains(encoded, expected) { - t.Fatalf("missing %q in encoded output: %s", expected, encoded) + + 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) } } } diff --git a/go/internal/hooks/semantic_policy_injection.go b/go/internal/hooks/semantic_policy_injection.go index b0592d0d..a1e557ac 100644 --- a/go/internal/hooks/semantic_policy_injection.go +++ b/go/internal/hooks/semantic_policy_injection.go @@ -108,18 +108,60 @@ func shellCommandMutatesGit(command shellparse.Command) bool { switch shellCommandName(command) { case tokenGit: - return len(args) > 1 && semanticGitMutation(args[1]) + return semanticGitMutation(semanticGitSubcommand(args[1:])) case wrapperRunnerName: return len(args) > 2 && args[1] == "policy-git" && - semanticGitMutation(args[2]) + semanticGitMutation(semanticGitSubcommand(args[2:])) case "coding-ethos-git": - return len(args) > 1 && semanticGitMutation(args[1]) + 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, "-") { 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) + } + }) + } +} From a4279fed000009a9e31236e8efaf04fb68e853a5 Mon Sep 17 00:00:00 2001 From: Patrick_Audley Date: Wed, 24 Jun 2026 09:00:37 -0600 Subject: [PATCH 3/3] fix(hooks): remove stale go worktree helper --- go/internal/hookrunnercli/toolchain_groups.go | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/go/internal/hookrunnercli/toolchain_groups.go b/go/internal/hookrunnercli/toolchain_groups.go index 76651d4d..490e0daf 100644 --- a/go/internal/hookrunnercli/toolchain_groups.go +++ b/go/internal/hookrunnercli/toolchain_groups.go @@ -840,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 {