diff --git a/cmd/sin-code/chat_cmd.go b/cmd/sin-code/chat_cmd.go index bb54de5e..b0bb532e 100644 --- a/cmd/sin-code/chat_cmd.go +++ b/cmd/sin-code/chat_cmd.go @@ -444,6 +444,7 @@ func runChat(ctx context.Context, opts *chatOptions) error { Ask: ask, ThinkingEnabled: thinkingCfg.Enabled, ThinkingBudgetPerRequest: thinkingCfg.Budget, + ResultPolicy: permission.NewResultPolicy(), } if opts.repetitionThreshold > 0 { diff --git a/cmd/sin-code/internal/agentloop/loop.go b/cmd/sin-code/internal/agentloop/loop.go index d7e4d5f0..91389353 100644 --- a/cmd/sin-code/internal/agentloop/loop.go +++ b/cmd/sin-code/internal/agentloop/loop.go @@ -239,6 +239,12 @@ type Loop struct { // == "poc" (issue #290). TournamentRunner TournamentRunner + // ResultPolicy, if set, scans the string returned by every executed + // tool and surfaces warnings/escalations for secret leakage, + // destructive confirmations, or network egress markers (issue #374). + // Optional — nil preserves exact legacy behavior. + ResultPolicy *permission.ResultPolicy + // Frustration, when set, tracks user message patterns for frustration // signals and appends a system-prompt suffix when detected (issue #271). // Optional — nil preserves legacy behavior. @@ -627,6 +633,23 @@ func (l *Loop) execute(ctx context.Context, tc ToolCall) (out string, injects [] if p := mutatedPath(tc); p != "" { postData["path"] = p } + if l.ResultPolicy != nil { + action, reason := l.ResultPolicy.ScanResult(tc.Name, res) + if action != permission.ActionNoOp { + postData["result_policy_action"] = action.String() + postData["result_policy_reason"] = reason + l.record(ctx, ledger.TypePermissionResult, map[string]any{ + "tool": tc.Name, + "action": action.String(), + "reason": reason, + }, "reactive permission: "+action.String()+" — "+reason) + if action == permission.ActionEscalate { + injects = append(injects, "PERMISSION ESCALATION: tool "+tc.Name+" output triggered '"+reason+"'. Stop and review before continuing.") + } else { + injects = append(injects, "PERMISSION WARNING: tool "+tc.Name+" output triggered '"+reason+"'.") + } + } + } post := l.fire(ctx, hooks.ToolPost, tc.Name, postData) injects = append(injects, post.PromptInjects...) l.record(ctx, ledger.TypeToolCall, map[string]any{"tool": tc.Name}, "tool call: "+tc.Name) diff --git a/cmd/sin-code/internal/ledger/store.go b/cmd/sin-code/internal/ledger/store.go index 5151c7be..35e0e44b 100644 --- a/cmd/sin-code/internal/ledger/store.go +++ b/cmd/sin-code/internal/ledger/store.go @@ -55,6 +55,9 @@ const ( // TypeFusionTournament records a multi-provider verify-tournament // outcome (issue #290). TypeFusionTournament EntryType = "fusion_tournament" + // TypePermissionResult records a reactive permission policy decision + // taken after scanning a tool result (issue #374). + TypePermissionResult EntryType = "permission_result" ) // Entry is one row in the ledger. diff --git a/cmd/sin-code/internal/loopbuilder/builder.go b/cmd/sin-code/internal/loopbuilder/builder.go index c59f1003..f0551177 100644 --- a/cmd/sin-code/internal/loopbuilder/builder.go +++ b/cmd/sin-code/internal/loopbuilder/builder.go @@ -375,6 +375,7 @@ func Build(ctx context.Context, cfg Config, memStore *lessons.Store) (*agentloop CoverageForbiddenTools: cfg.CoverageForbiddenTools, ThinkingEnabled: thinkingCfg.Enabled, ThinkingBudgetPerRequest: thinkingCfg.Budget, + ResultPolicy: permission.NewResultPolicy(), } if cfg.RepetitionThreshold > 0 { @@ -607,6 +608,7 @@ func WireFusion(loop *agentloop.Loop, cfg Config, gate *verify.Gate, client *llm Perm: loop.Perm, Lessons: memStore, Ledger: ledgerStore, + ResultPolicy: loop.ResultPolicy, } return provLoop.Run(ctx, sess, prompt) } diff --git a/cmd/sin-code/internal/permission/result_policy.go b/cmd/sin-code/internal/permission/result_policy.go new file mode 100644 index 00000000..95c334ce --- /dev/null +++ b/cmd/sin-code/internal/permission/result_policy.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// Purpose: reactive permission policy that scans tool *results* after +// execution and adjusts future posture when sensitive patterns are +// detected (issue #374). Patterns include secret/token leakage, +// destructive confirmations, and network egress markers. +package permission + +import ( + "regexp" + "strings" + "sync" +) + +// PolicyAction is the recommended reactive action after scanning a tool +// result. It is advisory: the caller (agent loop) decides how to surface +// the warning and whether to block subsequent work. +type PolicyAction int + +const ( + // ActionNoOp means the result contained no reactive-policy triggers. + ActionNoOp PolicyAction = iota + // ActionWarn means the result contains a pattern that should be + // surfaced to the model/operator but need not stop the run. + ActionWarn + // ActionEscalate means the result contains a high-sensitivity pattern + // (e.g., credential leakage) that should be recorded prominently and + // may prompt re-authorization. + ActionEscalate +) + +// String returns the canonical lowercase name of the action. +func (a PolicyAction) String() string { + switch a { + case ActionWarn: + return "warn" + case ActionEscalate: + return "escalate" + default: + return "noop" + } +} + +// ResultPolicy scans tool outputs for reactive patterns. It is safe for +// concurrent use: regular expressions are compiled exactly once via +// sync.Once and then only read. +type ResultPolicy struct { + secretRe *regexp.Regexp + destructiveRe *regexp.Regexp + networkRe *regexp.Regexp + once sync.Once +} + +// NewResultPolicy creates a reactive result scanner. Regexes are compiled +// lazily on the first ScanResult call to keep startup cost minimal. +func NewResultPolicy() *ResultPolicy { + return &ResultPolicy{} +} + +func (rp *ResultPolicy) compile() { + rp.once.Do(func() { + // AWS Access Key ID (AKIA...), JWT-shaped blobs, and common + // GitHub/GitLab token prefixes. These are heuristic: they catch + // accidental paste in tool output, not every possible secret. + rp.secretRe = regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16}|` + + `eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*|` + + `ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9\-]{20,}|` + + `\b(?:api[_-]?key|apikey|secret|token|password)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/]{16,}['"]?` + + `)`) + // Destructive operation confirmations. + rp.destructiveRe = regexp.MustCompile(`(?i)\b(deleted|removed|destroyed|dropped|purged|truncated|wiped)\b`) + // Network egress markers (optional, conservative). + rp.networkRe = regexp.MustCompile(`(?i)\b(egress|outbound|external\s+(?:ip|host|domain|address))\b`) + }) +} + +// ScanResult inspects a tool result and returns a recommended action plus +// a short human-readable reason. The tool name is included because some +// tools (e.g., secret scanners) are expected to mention secrets; for most +// tools, mentioning a secret is a leakage signal. +func (rp *ResultPolicy) ScanResult(toolName, result string) (PolicyAction, string) { + rp.compile() + t := strings.ToLower(toolName) + + // Secret scanners are allowed to emit secret-like strings; do not + // escalate them. Other tools mentioning these patterns are suspect. + if !strings.Contains(t, "secret") && !strings.Contains(t, "scan") && rp.secretRe.MatchString(result) { + return ActionEscalate, "possible secret/token leakage in tool output" + } + if rp.destructiveRe.MatchString(result) { + return ActionWarn, "destructive operation confirmed in tool output" + } + if rp.networkRe.MatchString(result) { + return ActionWarn, "network egress marker detected in tool output" + } + return ActionNoOp, "" +} + +// SampleDetections returns a few deterministic (tool, result) pairs useful +// for demos and CLI smoke tests. None of the strings are real credentials. +func SampleDetections() []SampleDetection { + return []SampleDetection{ + { + Tool: "sin_test", + Result: "ok 1 test passed", + }, + { + Tool: "sin_bash", + Result: "removed 42 files and deleted directory /tmp/old", + }, + { + Tool: "sin_http_get", + Result: "fetched https://api.example.com/v1/data (egress via outbound proxy)", + }, + { + Tool: "aws_cli", + Result: "AKIAIOSFODNN7EXAMPLE", + }, + { + Tool: "cat_env", + Result: "api_key = '1234567890abcdef1234567890abcdef'", + }, + } +} + +// SampleDetection pairs a tool name with a result string for demo purposes. +type SampleDetection struct { + Tool string + Result string +} diff --git a/cmd/sin-code/internal/permission/result_policy_test.go b/cmd/sin-code/internal/permission/result_policy_test.go new file mode 100644 index 00000000..be406eb0 --- /dev/null +++ b/cmd/sin-code/internal/permission/result_policy_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for the reactive permission result scanner (issue #374). +// All tests are designed to pass with -race. +package permission + +import ( + "strings" + "sync" + "testing" +) + +func TestResultPolicy_BenignNoOp(t *testing.T) { + rp := NewResultPolicy() + cases := []string{ + "all tests passed", + "ok 42 test cases", + "found 3 matching files", + "build succeeded", + } + for _, c := range cases { + act, reason := rp.ScanResult("sin_test", c) + if act != ActionNoOp { + t.Errorf("%q: expected noop, got %s (%q)", c, act, reason) + } + } +} + +func TestResultPolicy_DestructiveWarn(t *testing.T) { + rp := NewResultPolicy() + cases := []struct { + tool string + result string + }{ + {"sin_bash", "removed directory /tmp/old"}, + {"sin_rm", "deleted 12 files"}, + {"sin_db", "truncated table users"}, + } + for _, c := range cases { + act, reason := rp.ScanResult(c.tool, c.result) + if act != ActionWarn { + t.Errorf("%q: expected warn, got %s", c.result, act) + } + if !strings.Contains(reason, "destructive") { + t.Errorf("%q: expected destructive reason, got %q", c.result, reason) + } + } +} + +func TestResultPolicy_NetworkEgressWarn(t *testing.T) { + rp := NewResultPolicy() + act, reason := rp.ScanResult("sin_probe", "outbound connection to external host 1.2.3.4") + if act != ActionWarn { + t.Errorf("expected warn, got %s", act) + } + if !strings.Contains(reason, "egress") { + t.Errorf("expected egress reason, got %q", reason) + } +} + +func TestResultPolicy_SecretEscalate(t *testing.T) { + rp := NewResultPolicy() + cases := []struct { + tool string + result string + }{ + {"aws_cli", "AKIAIOSFODNN7EXAMPLE"}, + {"cat_env", "api_key=1234567890abcdef1234567890abcdef"}, + {"curl", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}, + } + for _, c := range cases { + act, reason := rp.ScanResult(c.tool, c.result) + if act != ActionEscalate { + t.Errorf("%q: expected escalate, got %s", c.result, act) + } + if !strings.Contains(reason, "secret") && !strings.Contains(reason, "token") { + t.Errorf("%q: expected secret/token reason, got %q", c.result, reason) + } + } +} + +func TestResultPolicy_SecretScannerAllowed(t *testing.T) { + rp := NewResultPolicy() + // A secret-scanning tool is expected to mention secrets; we should not + // escalate it as if it leaked the secret itself. + result := "found api_key=1234567890abcdef1234567890abcdef in file.env" + act, reason := rp.ScanResult("sin_security_scan", result) + if act != ActionNoOp { + t.Errorf("secret scanner should be allowed to mention secrets, got %s (%q)", act, reason) + } +} + +func TestResultPolicy_ActionString(t *testing.T) { + if ActionNoOp.String() != "noop" { + t.Errorf("noop string = %q", ActionNoOp.String()) + } + if ActionWarn.String() != "warn" { + t.Errorf("warn string = %q", ActionWarn.String()) + } + if ActionEscalate.String() != "escalate" { + t.Errorf("escalate string = %q", ActionEscalate.String()) + } +} + +func TestResultPolicy_SampleDetections(t *testing.T) { + rp := NewResultPolicy() + samples := SampleDetections() + if len(samples) == 0 { + t.Fatal("expected sample detections") + } + warnOrEscalate := 0 + for _, s := range samples { + act, _ := rp.ScanResult(s.Tool, s.Result) + if act == ActionWarn || act == ActionEscalate { + warnOrEscalate++ + } + } + if warnOrEscalate == 0 { + t.Error("expected at least one sample to trigger a reactive policy") + } +} + +// TestResultPolicy_ConcurrentCompileRace runs many concurrent scans to +// ensure the lazy sync.Once regex compilation is race-free (mandate M7). +func TestResultPolicy_ConcurrentCompileRace(t *testing.T) { + const workers = 50 + const iterations = 100 + + rp := NewResultPolicy() + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func(idx int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + if idx%2 == 0 { + rp.ScanResult("sin_test", "ok") + } else { + rp.ScanResult("sin_bash", "removed file") + } + } + }(i) + } + wg.Wait() +} diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index 658db057..1afe3cce 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -108,6 +108,7 @@ func init() { NewStatusCmd(), // v3.22.0 NewFusionCmd(), // v3.22.0 — fusion benchmark/rank/recommend (issue #395) — readiness/status snapshot (issue #326) NewResearchCmd(), // v3.23.0 — autonomous research-report generation (issue #384) + NewPermissionCmd(), // v3.23.0 — reactive permission policy from tool results (issue #374) ) // Pass build-time version to self-update module. diff --git a/cmd/sin-code/permission_cmd.go b/cmd/sin-code/permission_cmd.go new file mode 100644 index 00000000..2efaf869 --- /dev/null +++ b/cmd/sin-code/permission_cmd.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code permission` — inspect and smoke-test the reactive +// permission policy engine (issue #374). Subcommand: result-log. +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/permission" +) + +// NewPermissionCmd builds the `permission` cobra subcommand group. +func NewPermissionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "permission", + Short: "Reactive permission engine utilities", + Long: `sin-code permission inspects and smoke-tests the reactive +permission policy that scans tool results after execution for secret +leakage, destructive confirmations, and network egress markers. + +Subcommands: + + result-log print sample detections using the built-in pattern set`, + } + cmd.AddCommand(newPermissionResultLogCmd()) + return cmd +} + +func newPermissionResultLogCmd() *cobra.Command { + return &cobra.Command{ + Use: "result-log", + Short: "Print sample reactive-permission detections", + Long: `result-log runs the built-in ResultPolicy scanner over a +fixed set of synthetic tool outputs and prints the action/reason for each. +No real credentials are used; the samples are deterministic demo strings.`, + RunE: func(_ *cobra.Command, _ []string) error { + scanner := permission.NewResultPolicy() + for _, s := range permission.SampleDetections() { + action, reason := scanner.ScanResult(s.Tool, s.Result) + fmt.Printf("tool=%-12s action=%-9s reason=%q sample=%q\n", + s.Tool, action.String(), reason, truncateSample(s.Result, 64)) + } + return nil + }, + } +} + +func truncateSample(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-3] + "..." +} diff --git a/cmd/sin-code/testdata/scripts/golden_help.txt b/cmd/sin-code/testdata/scripts/golden_help.txt index 89917b8a..c1b1f3a8 100644 --- a/cmd/sin-code/testdata/scripts/golden_help.txt +++ b/cmd/sin-code/testdata/scripts/golden_help.txt @@ -49,6 +49,7 @@ Available Commands: eval Run Golden Dataset evaluation suites evalset Eval-driven development harness (EvalSets, Runs, regression compare) execute Execute shell commands safely with secret redaction and timeout + fusion SIN Fusion - status, config, benchmarking, model selection gh Bridge to the GitHub CLI (gh) with a 3-tier verb-allowlist policy goal Manage the autonomous goal queue grasp Deep code understanding for a single file @@ -74,11 +75,13 @@ Available Commands: orchestrator-agents List available agents (default + user-defined + plugin) orchestrator-plan Build a plan from a prompt (no execution) orchestrator-run Run a prompt through the multi-agent orchestrator (Pre-LLM router → planner → parallel agents) + permission Reactive permission engine utilities plugin Manage user-installed plugins (subcommands, agents, tools, hooks) poc Proof-of-Correctness — verify code satisfies its specification profile Render & verify the single-source-of-truth agent profile (issue #175) prp Product Requirement Prompt workflow (plan→implement→verify→pr) read Read files with hashline anchors, outline, and size guards + research Autonomous research-report generation (issue #384) review Review code for complexity and other quality dimensions rewind Restore the workspace to a prior checkpoint rtk Bridge to rtk (Rust Token Killer) to cut LLM token usage 60-90% @@ -94,6 +97,7 @@ Available Commands: skills List and install bundled project-local skills spec Author, validate & inspect *.spec.md contracts (Spec-Layer) stack Manage the DOX + Superpowers + Vane methodology stack + status Print a readiness snapshot of the local SIN-Code state subagent Run a subtask in an isolated session, return summary (issue #192) summary Build a deterministic summary from the session ledger superpowers Integrate obra/superpowers skills into SIN-Code