diff --git a/pkg/agent/context.go b/pkg/agent/context.go index b5c68650a..4a5040b2d 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -26,6 +26,7 @@ type ContextBuilder struct { toolDiscoveryBM25 bool toolDiscoveryRegex bool splitOnMarker bool + skillManageEnabled bool // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -57,6 +58,11 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { return cb } +func (cb *ContextBuilder) WithSkillManage(enabled bool) *ContextBuilder { + cb.skillManageEnabled = enabled + return cb +} + func getGlobalConfigDir() string { return config.GetHome() } @@ -80,51 +86,43 @@ func NewContextBuilder(workspace string) *ContextBuilder { func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) - toolDiscovery := cb.getDiscoveryRule() version := config.FormatVersion() - return fmt.Sprintf( - `# picoclaw 🦞 (%s) - -You are picoclaw, a helpful AI assistant. - -## Workspace -Your workspace is at: %s -- Memory: %s/memory/MEMORY.md -- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md -- Skills: %s/skills/{skill-name}/SKILL.md - -## Important Rules - -1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. - -2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. - -3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md - -4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content. - -%s`, - version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) -} - -func (cb *ContextBuilder) getDiscoveryRule() string { - if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex { - return "" + // Build rules dynamically so numbering adapts to conditional rules. + rules := []string{ + `**ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.`, + `**Be helpful and accurate** - When using tools, briefly explain what you're doing.`, + fmt.Sprintf(`**Memory** - Save durable facts to %s/memory/MEMORY.md: user preferences, environment details, tool quirks, and stable conventions. Keep it compact. Prioritize what reduces future user corrections. Do NOT save task progress, session outcomes, or temporary state. If you discovered a reusable procedure, save it as a skill instead.`, workspacePath), + `**Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, } - var toolNames []string - if cb.toolDiscoveryBM25 { - toolNames = append(toolNames, `"tool_search_tool_bm25"`) + if cb.skillManageEnabled { + rules = append(rules, `**Skills** - After completing a complex task (5+ tool calls), fixing a tricky error, or discovering a non-trivial workflow, save the approach as a skill with skill_manage so you can reuse it next time. When using a skill and finding it outdated, incomplete, or wrong, patch it immediately with skill_manage(operation='patch') -- do not wait to be asked. Skills that are not maintained become liabilities.`) } - if cb.toolDiscoveryRegex { - toolNames = append(toolNames, `"tool_search_tool_regex"`) + + if cb.toolDiscoveryBM25 || cb.toolDiscoveryRegex { + var toolNames []string + if cb.toolDiscoveryBM25 { + toolNames = append(toolNames, `"tool_search_tool_bm25"`) + } + if cb.toolDiscoveryRegex { + toolNames = append(toolNames, `"tool_search_tool_regex"`) + } + rules = append(rules, fmt.Sprintf(`**Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`, strings.Join(toolNames, " or "))) + } + + // Format numbered rules. + var rulesText strings.Builder + for i, rule := range rules { + fmt.Fprintf(&rulesText, "%d. %s", i+1, rule) + if i < len(rules)-1 { + rulesText.WriteString("\n\n") + } } return fmt.Sprintf( - `5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`, - strings.Join(toolNames, " or "), - ) + "# picoclaw 🦞 (%s)\n\nYou are picoclaw, a helpful AI assistant.\n\n## Workspace\nYour workspace is at: %s\n- Memory: %s/memory/MEMORY.md\n- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md\n- Skills: %s/skills/{skill-name}/SKILL.md\n\n## Important Rules\n\n%s", + version, workspacePath, workspacePath, workspacePath, workspacePath, rulesText.String()) } func (cb *ContextBuilder) BuildSystemPrompt() string { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 880725660..dfea520ba 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -111,7 +111,8 @@ func NewAgentInstance( mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, ). - WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker) + WithSplitOnMarker(cfg.Agents.Defaults.SplitOnMarker). + WithSkillManage(cfg.Tools.IsToolEnabled("skill_manage")) agentID := routing.DefaultAgentID agentName := "" diff --git a/pkg/skills/guard.go b/pkg/skills/guard.go new file mode 100644 index 000000000..3e3584660 --- /dev/null +++ b/pkg/skills/guard.go @@ -0,0 +1,429 @@ +package skills + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// Severity levels for security findings. +type Severity string + +const ( + SeverityCritical Severity = "critical" + SeverityHigh Severity = "high" + SeverityMedium Severity = "medium" + SeverityLow Severity = "low" +) + +// TrustLevel for skill sources. +type TrustLevel string + +const ( + TrustBuiltin TrustLevel = "builtin" + TrustTrusted TrustLevel = "trusted" + TrustCommunity TrustLevel = "community" + TrustAgentCreated TrustLevel = "agent-created" +) + +// Verdict is the overall scan result. +type Verdict string + +const ( + VerdictSafe Verdict = "safe" + VerdictCaution Verdict = "caution" + VerdictDangerous Verdict = "dangerous" +) + +// Finding represents a single security issue detected. +type Finding struct { + PatternID string + Severity Severity + Category string + File string + Line int + Match string + Description string +} + +// ScanResult is the output of scanning a skill. +type ScanResult struct { + SkillName string + Source TrustLevel + Verdict Verdict + Findings []Finding + ScannedAt time.Time + Summary string +} + +// threatPattern is a pre-compiled regex threat pattern. +type threatPattern struct { + id string + re *regexp.Regexp + severity Severity + category string + description string +} + +// Structural limits. +const ( + maxFileCount = 50 + maxTotalSizeKB = 1024 + maxSingleFileKB = 256 +) + +// scannableExtensions are file types we check for threat patterns. +var scannableExtensions = map[string]bool{ + ".md": true, ".txt": true, ".py": true, ".sh": true, ".bash": true, + ".js": true, ".ts": true, ".rb": true, ".yaml": true, ".yml": true, + ".json": true, ".toml": true, ".cfg": true, ".ini": true, ".conf": true, + ".html": true, ".css": true, ".xml": true, ".go": true, +} + +// suspiciousBinaryExtensions that should never be in a skill. +var suspiciousBinaryExtensions = map[string]bool{ + ".exe": true, ".dll": true, ".so": true, ".dylib": true, ".bin": true, + ".dat": true, ".com": true, ".msi": true, ".dmg": true, ".app": true, + ".deb": true, ".rpm": true, +} + +// invisibleUnicodeChars that may indicate hidden content. +var invisibleUnicodeChars = []rune{ + '\u200B', // zero-width space + '\u200C', // zero-width non-joiner + '\u200D', // zero-width joiner + '\u200E', // left-to-right mark + '\u200F', // right-to-left mark + '\u2060', // word joiner + '\u2061', // function application + '\u2062', // invisible times + '\u2063', // invisible separator + '\u2064', // invisible plus + '\uFEFF', // zero-width no-break space (BOM) + '\u00AD', // soft hyphen + '\u034F', // combining grapheme joiner + '\u061C', // arabic letter mark + '\u180E', // mongolian vowel separator + '\u202A', // left-to-right embedding + '\u202B', // right-to-left embedding +} + +// guardThreatPatterns holds all compiled patterns. Initialized once at package load. +var guardThreatPatterns []threatPattern + +func init() { + raw := []struct { + id, pattern, category, description string + severity Severity + }{ + // --- Exfiltration --- + {"exfil-curl-secret", `(?i)curl\s.*\$[A-Z_]+`, "exfiltration", "curl with environment variable (potential secret exfil)", SeverityCritical}, + {"exfil-wget-secret", `(?i)wget\s.*\$[A-Z_]+`, "exfiltration", "wget with environment variable (potential secret exfil)", SeverityCritical}, + {"exfil-env-dump", `(?i)(printenv|env\s*>|set\s*>|export\s+-p)\s*[|>]`, "exfiltration", "environment variable dump to file/pipe", SeverityHigh}, + {"exfil-dns-tunnel", `(?i)\$[A-Z_]+\.[a-z]+\.(com|net|org|io)`, "exfiltration", "DNS tunneling pattern (secret in subdomain)", SeverityCritical}, + {"exfil-base64-env", `(?i)(echo|printf)\s+\$[A-Z_]+\s*\|\s*base64`, "exfiltration", "base64 encoding of environment variable", SeverityHigh}, + {"exfil-markdown-img", `(?i)!\[.*\]\(https?://.*\$`, "exfiltration", "markdown image with variable (potential exfil via URL)", SeverityHigh}, + {"exfil-nc-data", `(?i)nc\s+.*\s+.*<\s*[/~]`, "exfiltration", "netcat sending file contents", SeverityCritical}, + {"exfil-ssh-key", `(?i)(cat|less|more|head|tail)\s+.*\.(ssh|aws|gnupg)/`, "exfiltration", "reading sensitive credential directories", SeverityCritical}, + + // --- Prompt Injection --- + {"inject-ignore-prev", `(?i)ignore\s+(all\s+)?previous\s+instructions`, "injection", "prompt injection: ignore previous instructions", SeverityCritical}, + {"inject-new-role", `(?i)you\s+are\s+now\s+(a|an)\s+`, "injection", "prompt injection: role reassignment", SeverityHigh}, + {"inject-system-override", `(?i)(system\s*prompt|system\s*message)\s*[:=]`, "injection", "prompt injection: system prompt override", SeverityCritical}, + {"inject-act-as", `(?i)act\s+as\s+(if|though)?\s*(you|a|an)\s+`, "injection", "prompt injection: act-as directive", SeverityHigh}, + {"inject-disregard", `(?i)disregard\s+(all\s+)?(previous|prior|above)`, "injection", "prompt injection: disregard instructions", SeverityCritical}, + {"inject-jailbreak", `(?i)(DAN|do\s+anything\s+now|jailbreak)`, "injection", "prompt injection: known jailbreak pattern", SeverityCritical}, + {"inject-restriction-bypass", `(?i)(bypass|circumvent|override)\s+(safety|restriction|filter|guard)`, "injection", "prompt injection: safety bypass attempt", SeverityHigh}, + {"inject-html-hidden", `