Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/sin-code/chat_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions cmd/sin-code/internal/agentloop/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions cmd/sin-code/internal/ledger/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions cmd/sin-code/internal/loopbuilder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
129 changes: 129 additions & 0 deletions cmd/sin-code/internal/permission/result_policy.go
Original file line number Diff line number Diff line change
@@ -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",
},

Check failure

Code scanning / gosec

Potential hardcoded credentials: Generic API Key Error

Potential hardcoded credentials: AWS API Key
Comment on lines +114 to +117
{
Tool: "cat_env",
Result: "api_key = '1234567890abcdef1234567890abcdef'",
},

Check failure

Code scanning / gosec

Potential hardcoded credentials: Generic API Key Error

Potential hardcoded credentials: Generic API Key
Comment on lines +118 to +121
}
}

// SampleDetection pairs a tool name with a result string for demo purposes.
type SampleDetection struct {
Tool string
Result string
}
144 changes: 144 additions & 0 deletions cmd/sin-code/internal/permission/result_policy_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
1 change: 1 addition & 0 deletions cmd/sin-code/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading