feat(plugin): add policy demo plugin with runtime enforcement cases
This commit is contained in:
parent
c9ca0835ae
commit
d8cca6810a
3 changed files with 547 additions and 0 deletions
71
docs/design/plugin-code-plan-demo.md
Normal file
71
docs/design/plugin-code-plan-demo.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Plugin System Demo Code Plan
|
||||
|
||||
## Goal
|
||||
Build a minimal, executable demo to prove plugin value with measurable behavior (not conceptual discussion).
|
||||
|
||||
## Why this demo
|
||||
This demo validates runtime capabilities that skill text cannot reliably enforce:
|
||||
- deterministic tool-call blocking at runtime
|
||||
- deterministic outbound content rewrite at runtime
|
||||
- reversible behavior (no plugin config => no effect)
|
||||
|
||||
## Scope (1-day demo)
|
||||
In scope:
|
||||
- add one compile-time plugin: `policy-demo`
|
||||
- add tests for:
|
||||
- global tool block
|
||||
- channel-specific tool allowlist
|
||||
- outbound redaction
|
||||
- outbound deny-pattern guard
|
||||
- tool argument normalization (timeout clamp)
|
||||
- hook-based audit counters
|
||||
- no-config no-effect path
|
||||
- provide a reviewer-friendly verification command
|
||||
|
||||
Out of scope:
|
||||
- dynamic plugin loading
|
||||
- plugin marketplace/distribution
|
||||
- UI/config schema work
|
||||
|
||||
## Code Changes
|
||||
1. `pkg/plugin/demoplugin/policy_demo.go`
|
||||
- add `PolicyDemoPlugin`
|
||||
- register `before_tool_call` hook to block configured tools
|
||||
- support channel-specific tool allowlist
|
||||
- normalize timeout-like tool args (`timeout`, `timeout_seconds`)
|
||||
- register `message_sending` hook for redaction and deny-pattern guard
|
||||
- register `session_start`, `session_end`, `after_tool_call` audit hooks
|
||||
- expose `Snapshot()` for deterministic verification
|
||||
|
||||
2. `pkg/plugin/demoplugin/policy_demo_test.go`
|
||||
- `TestPolicyDemoPluginBlocksConfiguredTool`
|
||||
- `TestPolicyDemoPluginRedactsOutboundContent`
|
||||
- `TestPolicyDemoPluginChannelAllowlist`
|
||||
- `TestPolicyDemoPluginOutboundGuard`
|
||||
- `TestPolicyDemoPluginNormalizesTimeoutArg`
|
||||
- `TestPolicyDemoPluginAuditHooks`
|
||||
- `TestPolicyDemoPluginNoConfigNoEffect`
|
||||
|
||||
## Verification
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./pkg/plugin/... ./pkg/agent/...
|
||||
```
|
||||
|
||||
Expected:
|
||||
- all tests pass
|
||||
- plugin test suite demonstrates deterministic runtime interception and rewrite
|
||||
|
||||
## Acceptance Criteria
|
||||
- plugin can enforce a hard runtime policy (`before_tool_call` cancel)
|
||||
- plugin can enforce channel-level runtime policy without core code changes
|
||||
- plugin can enforce outbound transformation (`message_sending` rewrite)
|
||||
- plugin can enforce outbound hard-block (`message_sending` cancel)
|
||||
- plugin can mutate tool args before execution in a deterministic way
|
||||
- plugin can collect lifecycle audit counters
|
||||
- empty plugin config causes zero behavior change
|
||||
- behavior is covered by automated tests
|
||||
|
||||
## Reviewer Notes
|
||||
If this demo is accepted, next step is wiring a config-driven enable/disable path (Roadmap Phase 2), while keeping current compile-time contract (`pkg/plugin`) unchanged.
|
||||
303
pkg/plugin/demoplugin/policy_demo.go
Normal file
303
pkg/plugin/demoplugin/policy_demo.go
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
package demoplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/hooks"
|
||||
)
|
||||
|
||||
// PolicyDemoConfig controls the demo plugin behavior.
|
||||
type PolicyDemoConfig struct {
|
||||
BlockedTools []string
|
||||
RedactPrefixes []string
|
||||
ChannelToolAllowlist map[string][]string
|
||||
DenyOutboundPatterns []string
|
||||
MaxToolTimeoutSecond int
|
||||
}
|
||||
|
||||
// PolicyDemoStats provides basic evidence that hook paths were executed.
|
||||
type PolicyDemoStats struct {
|
||||
BeforeToolCalls int
|
||||
BlockedToolCalls int
|
||||
MessageSends int
|
||||
RedactedMessages int
|
||||
BlockedMessages int
|
||||
SessionStarts int
|
||||
SessionEnds int
|
||||
AfterToolCalls int
|
||||
TotalToolDuration time.Duration
|
||||
}
|
||||
|
||||
// PolicyDemoPlugin demonstrates why plugins are needed: it enforces runtime policy
|
||||
// at tool-call and outbound-message lifecycle points and collects audit metrics.
|
||||
type PolicyDemoPlugin struct {
|
||||
blockedTools map[string]struct{}
|
||||
prefixes []string
|
||||
channelAllowlist map[string]map[string]struct{}
|
||||
denyPatterns []string
|
||||
maxTimeout int
|
||||
|
||||
mu sync.Mutex
|
||||
stats PolicyDemoStats
|
||||
}
|
||||
|
||||
func NewPolicyDemoPlugin(cfg PolicyDemoConfig) *PolicyDemoPlugin {
|
||||
blocked := make(map[string]struct{}, len(cfg.BlockedTools))
|
||||
for _, t := range cfg.BlockedTools {
|
||||
t = normalizeLower(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
blocked[t] = struct{}{}
|
||||
}
|
||||
|
||||
prefixes := make([]string, 0, len(cfg.RedactPrefixes))
|
||||
for _, p := range cfg.RedactPrefixes {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
|
||||
allowlist := make(map[string]map[string]struct{}, len(cfg.ChannelToolAllowlist))
|
||||
for channel, tools := range cfg.ChannelToolAllowlist {
|
||||
channel = normalizeLower(channel)
|
||||
if channel == "" {
|
||||
continue
|
||||
}
|
||||
toolSet := make(map[string]struct{}, len(tools))
|
||||
for _, t := range tools {
|
||||
t = normalizeLower(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
toolSet[t] = struct{}{}
|
||||
}
|
||||
allowlist[channel] = toolSet
|
||||
}
|
||||
|
||||
patterns := make([]string, 0, len(cfg.DenyOutboundPatterns))
|
||||
for _, p := range cfg.DenyOutboundPatterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, p)
|
||||
}
|
||||
|
||||
maxTimeout := cfg.MaxToolTimeoutSecond
|
||||
if maxTimeout < 0 {
|
||||
maxTimeout = 0
|
||||
}
|
||||
|
||||
return &PolicyDemoPlugin{
|
||||
blockedTools: blocked,
|
||||
prefixes: prefixes,
|
||||
channelAllowlist: allowlist,
|
||||
denyPatterns: patterns,
|
||||
maxTimeout: maxTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) Name() string {
|
||||
return "policy-demo"
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) Snapshot() PolicyDemoStats {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.stats
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) Register(r *hooks.HookRegistry) error {
|
||||
r.OnBeforeToolCall("policy-demo-tool-policy", 100, func(_ context.Context, e *hooks.BeforeToolCallEvent) error {
|
||||
tool := normalizeLower(e.ToolName)
|
||||
p.incBeforeToolCalls()
|
||||
|
||||
if _, blocked := p.blockedTools[tool]; blocked {
|
||||
e.Cancel = true
|
||||
e.CancelReason = "blocked by policy-demo plugin"
|
||||
p.incBlockedToolCalls()
|
||||
return nil
|
||||
}
|
||||
|
||||
channel := normalizeLower(e.Channel)
|
||||
if allow, ok := p.channelAllowlist[channel]; ok {
|
||||
if _, allowed := allow[tool]; !allowed {
|
||||
e.Cancel = true
|
||||
e.CancelReason = fmt.Sprintf("tool %q is not allowed on channel %q", e.ToolName, e.Channel)
|
||||
p.incBlockedToolCalls()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if p.maxTimeout > 0 {
|
||||
clampArgNumber(e.Args, "timeout", p.maxTimeout)
|
||||
clampArgNumber(e.Args, "timeout_seconds", p.maxTimeout)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
r.OnMessageSending("policy-demo-redact-and-guard", 50, func(_ context.Context, e *hooks.MessageSendingEvent) error {
|
||||
p.incMessageSends()
|
||||
|
||||
for _, pattern := range p.denyPatterns {
|
||||
if strings.Contains(e.Content, pattern) {
|
||||
e.Cancel = true
|
||||
e.CancelReason = "blocked by policy-demo outbound guard"
|
||||
p.incBlockedMessages()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
content := e.Content
|
||||
redacted := false
|
||||
for _, prefix := range p.prefixes {
|
||||
next := strings.ReplaceAll(content, prefix, "[redacted]-")
|
||||
if next != content {
|
||||
redacted = true
|
||||
}
|
||||
content = next
|
||||
}
|
||||
e.Content = content
|
||||
if redacted {
|
||||
p.incRedactedMessages()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
r.OnSessionStart("policy-demo-session-start-audit", 0, func(_ context.Context, _ *hooks.SessionEvent) error {
|
||||
p.incSessionStarts()
|
||||
return nil
|
||||
})
|
||||
|
||||
r.OnSessionEnd("policy-demo-session-end-audit", 0, func(_ context.Context, _ *hooks.SessionEvent) error {
|
||||
p.incSessionEnds()
|
||||
return nil
|
||||
})
|
||||
|
||||
r.OnAfterToolCall("policy-demo-after-tool-audit", 0, func(_ context.Context, e *hooks.AfterToolCallEvent) error {
|
||||
p.incAfterToolCall(e.Duration)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLower(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func clampArgNumber(args map[string]any, key string, max int) {
|
||||
if args == nil || max <= 0 {
|
||||
return
|
||||
}
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, ok := toInt(v)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if n > max {
|
||||
args[key] = max
|
||||
}
|
||||
}
|
||||
|
||||
func toInt(v any) (int, bool) {
|
||||
maxInt := int(^uint(0) >> 1)
|
||||
maxIntU64 := uint64(maxInt)
|
||||
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n, true
|
||||
case int8:
|
||||
return int(n), true
|
||||
case int16:
|
||||
return int(n), true
|
||||
case int32:
|
||||
return int(n), true
|
||||
case int64:
|
||||
return int(n), true
|
||||
case uint:
|
||||
if uint64(n) > maxIntU64 {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
case uint8:
|
||||
return int(n), true
|
||||
case uint16:
|
||||
return int(n), true
|
||||
case uint32:
|
||||
if uint64(n) > maxIntU64 {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
case uint64:
|
||||
if n > maxIntU64 {
|
||||
return 0, false
|
||||
}
|
||||
return int(n), true
|
||||
case float32:
|
||||
return int(n), true
|
||||
case float64:
|
||||
return int(n), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incBeforeToolCalls() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.BeforeToolCalls++
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incBlockedToolCalls() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.BlockedToolCalls++
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incMessageSends() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.MessageSends++
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incRedactedMessages() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.RedactedMessages++
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incBlockedMessages() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.BlockedMessages++
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incSessionStarts() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.SessionStarts++
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incSessionEnds() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.SessionEnds++
|
||||
}
|
||||
|
||||
func (p *PolicyDemoPlugin) incAfterToolCall(d time.Duration) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.stats.AfterToolCalls++
|
||||
p.stats.TotalToolDuration += d
|
||||
}
|
||||
173
pkg/plugin/demoplugin/policy_demo_test.go
Normal file
173
pkg/plugin/demoplugin/policy_demo_test.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package demoplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/hooks"
|
||||
"github.com/sipeed/picoclaw/pkg/plugin"
|
||||
)
|
||||
|
||||
func TestPolicyDemoPluginBlocksConfiguredTool(t *testing.T) {
|
||||
pm := plugin.NewManager()
|
||||
p := NewPolicyDemoPlugin(PolicyDemoConfig{
|
||||
BlockedTools: []string{"shell"},
|
||||
})
|
||||
if err := pm.Register(p); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
e := &hooks.BeforeToolCallEvent{ToolName: "shell", Args: map[string]any{}, Channel: "cli"}
|
||||
pm.HookRegistry().TriggerBeforeToolCall(context.Background(), e)
|
||||
|
||||
if !e.Cancel {
|
||||
t.Fatal("expected tool call to be canceled")
|
||||
}
|
||||
if e.CancelReason == "" {
|
||||
t.Fatal("expected cancel reason")
|
||||
}
|
||||
|
||||
stats := p.Snapshot()
|
||||
if stats.BeforeToolCalls != 1 || stats.BlockedToolCalls != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyDemoPluginRedactsOutboundContent(t *testing.T) {
|
||||
pm := plugin.NewManager()
|
||||
p := NewPolicyDemoPlugin(PolicyDemoConfig{
|
||||
RedactPrefixes: []string{"sk-"},
|
||||
})
|
||||
if err := pm.Register(p); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
e := &hooks.MessageSendingEvent{Content: "token=sk-abc123"}
|
||||
pm.HookRegistry().TriggerMessageSending(context.Background(), e)
|
||||
|
||||
if e.Cancel {
|
||||
t.Fatal("did not expect cancellation")
|
||||
}
|
||||
if e.Content != "token=[redacted]-abc123" {
|
||||
t.Fatalf("unexpected redaction result: %q", e.Content)
|
||||
}
|
||||
|
||||
stats := p.Snapshot()
|
||||
if stats.MessageSends != 1 || stats.RedactedMessages != 1 {
|
||||
t.Fatalf("unexpected stats: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyDemoPluginChannelAllowlist(t *testing.T) {
|
||||
pm := plugin.NewManager()
|
||||
p := NewPolicyDemoPlugin(PolicyDemoConfig{
|
||||
ChannelToolAllowlist: map[string][]string{
|
||||
"telegram": {"web_search"},
|
||||
},
|
||||
})
|
||||
if err := pm.Register(p); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
blocked := &hooks.BeforeToolCallEvent{ToolName: "shell", Args: map[string]any{}, Channel: "telegram"}
|
||||
pm.HookRegistry().TriggerBeforeToolCall(context.Background(), blocked)
|
||||
if !blocked.Cancel {
|
||||
t.Fatal("expected tool to be blocked by channel allowlist")
|
||||
}
|
||||
|
||||
allowed := &hooks.BeforeToolCallEvent{ToolName: "web_search", Args: map[string]any{}, Channel: "telegram"}
|
||||
pm.HookRegistry().TriggerBeforeToolCall(context.Background(), allowed)
|
||||
if allowed.Cancel {
|
||||
t.Fatalf("did not expect allowlisted tool to be blocked: %s", allowed.CancelReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyDemoPluginOutboundGuard(t *testing.T) {
|
||||
pm := plugin.NewManager()
|
||||
p := NewPolicyDemoPlugin(PolicyDemoConfig{
|
||||
DenyOutboundPatterns: []string{"4111-1111-1111-1111", "@corp.internal"},
|
||||
})
|
||||
if err := pm.Register(p); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
e := &hooks.MessageSendingEvent{Content: "card=4111-1111-1111-1111"}
|
||||
pm.HookRegistry().TriggerMessageSending(context.Background(), e)
|
||||
if !e.Cancel {
|
||||
t.Fatal("expected outbound message to be blocked")
|
||||
}
|
||||
if e.CancelReason == "" {
|
||||
t.Fatal("expected block reason")
|
||||
}
|
||||
|
||||
stats := p.Snapshot()
|
||||
if stats.BlockedMessages != 1 {
|
||||
t.Fatalf("expected blocked message count to be 1, got %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyDemoPluginNormalizesTimeoutArg(t *testing.T) {
|
||||
pm := plugin.NewManager()
|
||||
p := NewPolicyDemoPlugin(PolicyDemoConfig{MaxToolTimeoutSecond: 30})
|
||||
if err := pm.Register(p); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
e := &hooks.BeforeToolCallEvent{
|
||||
ToolName: "web_fetch",
|
||||
Channel: "cli",
|
||||
Args: map[string]any{
|
||||
"timeout": 120,
|
||||
"timeout_seconds": 90.0,
|
||||
},
|
||||
}
|
||||
pm.HookRegistry().TriggerBeforeToolCall(context.Background(), e)
|
||||
|
||||
if got, ok := e.Args["timeout"].(int); !ok || got != 30 {
|
||||
t.Fatalf("expected timeout to be clamped to 30, got %#v", e.Args["timeout"])
|
||||
}
|
||||
if got, ok := e.Args["timeout_seconds"].(int); !ok || got != 30 {
|
||||
t.Fatalf("expected timeout_seconds to be clamped to 30, got %#v", e.Args["timeout_seconds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyDemoPluginAuditHooks(t *testing.T) {
|
||||
pm := plugin.NewManager()
|
||||
p := NewPolicyDemoPlugin(PolicyDemoConfig{})
|
||||
if err := pm.Register(p); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
pm.HookRegistry().TriggerSessionStart(context.Background(), &hooks.SessionEvent{AgentID: "a1", SessionKey: "s1"})
|
||||
pm.HookRegistry().TriggerAfterToolCall(context.Background(), &hooks.AfterToolCallEvent{ToolName: "web_search", Duration: 45 * time.Millisecond})
|
||||
pm.HookRegistry().TriggerSessionEnd(context.Background(), &hooks.SessionEvent{AgentID: "a1", SessionKey: "s1"})
|
||||
|
||||
stats := p.Snapshot()
|
||||
if stats.SessionStarts != 1 || stats.SessionEnds != 1 {
|
||||
t.Fatalf("unexpected session stats: %+v", stats)
|
||||
}
|
||||
if stats.AfterToolCalls != 1 || stats.TotalToolDuration != 45*time.Millisecond {
|
||||
t.Fatalf("unexpected after_tool_call stats: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyDemoPluginNoConfigNoEffect(t *testing.T) {
|
||||
pm := plugin.NewManager()
|
||||
p := NewPolicyDemoPlugin(PolicyDemoConfig{})
|
||||
if err := pm.Register(p); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
toolEvent := &hooks.BeforeToolCallEvent{ToolName: "shell", Args: map[string]any{}, Channel: "telegram"}
|
||||
pm.HookRegistry().TriggerBeforeToolCall(context.Background(), toolEvent)
|
||||
if toolEvent.Cancel {
|
||||
t.Fatal("did not expect cancellation with empty config")
|
||||
}
|
||||
|
||||
msgEvent := &hooks.MessageSendingEvent{Content: "token=sk-abc123"}
|
||||
pm.HookRegistry().TriggerMessageSending(context.Background(), msgEvent)
|
||||
if msgEvent.Content != "token=sk-abc123" {
|
||||
t.Fatalf("did not expect content rewrite, got %q", msgEvent.Content)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue