From 75c9e8430928e17f1095224de799b6d378a947a7 Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Wed, 18 Feb 2026 23:41:33 +0000 Subject: [PATCH] feat(security,worker,agent,pcerrors): add security hardening + job worker + agent state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security: - jsonextract.go — hardened JSON extraction against prompt injection; multiple fallback strategies (regex, manual brace-scan, strip-think) - redact.go — auto-strip API keys, auth headers, PII from log strings - urlguard.go — SSRF blocklist (RFC1918 + link-local + metadata IPs) - vault.go — ChaCha20-Poly1305 encrypted secret storage with keyring worker: - worker.go — background job processor polling the jobs table; claim-via-UPDATE, exponential backoff, max_attempts enforcement agent: - kv.go — agent KV store accessor wrapping delegate.UpsertKV/GetKV - state_store.go — conversation + run + run_state persistence layer - offloading_tool_runtime.go — tool result offload coordinator; ShouldOffload threshold check + OffloadToolResult flow - tool_result_search.go — fetch offloaded tool results by run/step pcerrors: - pcerrors.go — errbuilder-based domain error types for picoclaw - cli.go — human-friendly CLI error rendering --- pkg/agent/kv.go | 13 ++ pkg/agent/offloading_tool_runtime.go | 277 +++++++++++++++++++++++++ pkg/agent/state_store.go | 195 ++++++++++++++++++ pkg/agent/tool_result_search.go | 289 +++++++++++++++++++++++++++ pkg/pcerrors/cli.go | 54 +++++ pkg/pcerrors/pcerrors.go | 174 ++++++++++++++++ pkg/security/jsonextract.go | 259 ++++++++++++++++++++++++ pkg/security/jsonextract_test.go | 232 +++++++++++++++++++++ pkg/security/redact.go | 123 ++++++++++++ pkg/security/redact_test.go | 126 ++++++++++++ pkg/security/urlguard.go | 116 +++++++++++ pkg/security/urlguard_test.go | 121 +++++++++++ pkg/security/vault.go | 99 +++++++++ pkg/security/vault_test.go | 103 ++++++++++ pkg/worker/worker.go | 193 ++++++++++++++++++ 15 files changed, 2374 insertions(+) create mode 100644 pkg/agent/kv.go create mode 100644 pkg/agent/offloading_tool_runtime.go create mode 100644 pkg/agent/state_store.go create mode 100644 pkg/agent/tool_result_search.go create mode 100644 pkg/pcerrors/cli.go create mode 100644 pkg/pcerrors/pcerrors.go create mode 100644 pkg/security/jsonextract.go create mode 100644 pkg/security/jsonextract_test.go create mode 100644 pkg/security/redact.go create mode 100644 pkg/security/redact_test.go create mode 100644 pkg/security/urlguard.go create mode 100644 pkg/security/urlguard_test.go create mode 100644 pkg/security/vault.go create mode 100644 pkg/security/vault_test.go create mode 100644 pkg/worker/worker.go diff --git a/pkg/agent/kv.go b/pkg/agent/kv.go new file mode 100644 index 000000000..75f5df1a9 --- /dev/null +++ b/pkg/agent/kv.go @@ -0,0 +1,13 @@ +package agent + +import "context" + +// KVDelegate is a minimal KV-like interface for offloading large artifacts +// (tool results, scratchpads) out of the LLM context window. +// +// Keys are logical paths (not filesystem paths). +type KVDelegate interface { + Put(ctx context.Context, key string, value []byte) error + Get(ctx context.Context, key string) ([]byte, error) + Scan(ctx context.Context, prefix string) ([]string, error) +} diff --git a/pkg/agent/offloading_tool_runtime.go b/pkg/agent/offloading_tool_runtime.go new file mode 100644 index 000000000..c19e6e0d8 --- /dev/null +++ b/pkg/agent/offloading_tool_runtime.go @@ -0,0 +1,277 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "charm.land/fantasy" + "github.com/sipeed/picoclaw/pkg/ids" + "github.com/sipeed/picoclaw/pkg/memory/sqlc" + "github.com/sipeed/picoclaw/pkg/pcerrors" +) + +const defaultToolMaxConcurrency = 4 + +type ctxStepIndexKey struct{} + +func WithStepIndex(ctx context.Context, stepIndex int) context.Context { + return context.WithValue(ctx, ctxStepIndexKey{}, stepIndex) +} + +func StepIndexFromCtx(ctx context.Context) int { + v := ctx.Value(ctxStepIndexKey{}) + if v == nil { + return 0 + } + if i, ok := v.(int); ok { + return i + } + return 0 +} + +// OffloadingToolRuntime wraps a base ToolRuntime and applies tool result +// offloading policy: +// - Always offload full results to KV delegate. +// - If result is below threshold, keep it inline as-is. +// - If above threshold, truncate inline output and include an index/instructions. +// - Store chunked payload for targeted retrieval. +type OffloadingToolRuntime struct { + Base fantasy.ToolRuntime + + KV KVDelegate + Queries *sqlc.Queries + + ConversationID ids.UUID + RunID ids.UUID + + ThresholdChars int + ChunkChars int +} + +func (r OffloadingToolRuntime) Execute(ctx context.Context, tools []fantasy.AgentTool, toolCalls []fantasy.ToolCallContent, _ func(result fantasy.ToolResultContent) error) ([]fantasy.ToolResultContent, error) { + if len(toolCalls) == 0 { + return nil, nil + } + if r.Base == nil { + r.Base = fantasy.DAGToolRuntime{MaxConcurrency: defaultToolMaxConcurrency} + } + if r.KV == nil { + return nil, pcerrors.New(pcerrors.CodeFailedPrecondition, "KV delegate is nil") + } + if r.Queries == nil { + return nil, pcerrors.New(pcerrors.CodeFailedPrecondition, "db queries is nil") + } + if r.ConversationID.IsZero() || r.RunID.IsZero() { + return nil, pcerrors.New(pcerrors.CodeInvalidArgument, "conversation_id/run_id is required") + } + + threshold := r.ThresholdChars + if threshold <= 0 { + threshold = 4_000 + } + chunkChars := r.ChunkChars + if chunkChars <= 0 { + chunkChars = 2_000 + } + + stepIndex := StepIndexFromCtx(ctx) + + results, err := r.Base.Execute(ctx, tools, toolCalls, nil) + if err != nil { + return nil, err + } + + for i := range results { + tc := toolCalls[i] + res := results[i] + + fullKey := toolResultFullKey(r.ConversationID, r.RunID, stepIndex, tc.ToolCallID) + + payload, payloadType, payloadText := toolResultPayload(res) + b, marshalErr := json.Marshal(payload) + if marshalErr != nil { + b = []byte(`{"error":"failed to marshal tool result payload"}`) + payloadType = "error" + } + + if putErr := r.KV.Put(ctx, fullKey, b); putErr != nil { + return nil, putErr + } + + preview := payloadText + chunkCount := int64(0) + + if payloadType == "text" && len([]rune(payloadText)) > threshold { + chunks := chunkString(payloadText, chunkChars) + chunkCount = int64(len(chunks)) + + for ci, chunk := range chunks { + chunkKey := toolResultChunkKey(r.ConversationID, r.RunID, stepIndex, tc.ToolCallID, ci) + if putErr := r.KV.Put(ctx, chunkKey, []byte(chunk)); putErr != nil { + return nil, putErr + } + } + + preview = truncateRunes(payloadText, threshold) + preview = strings.TrimSpace(preview) + "\n\n" + + "[TRUNCATED]\n" + + "- run_id: " + r.RunID.String() + "\n" + + "- tool_call_id: " + tc.ToolCallID + "\n" + + "- chunk_count: " + strconv.FormatInt(chunkCount, 10) + "\n" + + "Use tool_result_search to retrieve more (prefer chunk ranges)." + + results[i].Result = fantasy.ToolResultOutputContentText{Text: preview} + } + + if len([]rune(preview)) > 8_000 { + preview = truncateRunes(preview, 8_000) + } + + meta := map[string]any{ + "result_type": payloadType, + "step_index": stepIndex, + } + metaJSON, _ := json.Marshal(meta) + + var previewPtr *string + if strings.TrimSpace(preview) != "" { + previewPtr = &preview + } + + _, dbErr := r.Queries.AddAgentToolResult(ctx, sqlc.AddAgentToolResultParams{ + ID: ids.New(), + ConversationID: r.ConversationID, + RunID: r.RunID, + StepIndex: int64(stepIndex), + ToolCallID: tc.ToolCallID, + ToolName: tc.ToolName, + FullKey: fullKey, + Preview: previewPtr, + ChunkCount: chunkCount, + MetadataJson: metaJSON, + }) + if dbErr != nil { + return nil, dbErr + } + } + + return results, nil +} + +func toolResultFullKey(conversationID, runID ids.UUID, stepIndex int, toolCallID string) string { + return "tool_results/" + conversationID.String() + "/" + runID.String() + "/step_" + strconv.Itoa(stepIndex) + "/" + sanitizeKeyPart(toolCallID) + "/full.json" +} + +func toolResultChunkKey(conversationID, runID ids.UUID, stepIndex int, toolCallID string, chunkIndex int) string { + return "tool_results/" + conversationID.String() + "/" + runID.String() + "/step_" + strconv.Itoa(stepIndex) + "/" + sanitizeKeyPart(toolCallID) + "/chunks/" + fmt.Sprintf("%06d.txt", chunkIndex) +} + +func sanitizeKeyPart(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "empty" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_' || r == '.': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + out := strings.Trim(b.String(), "._") + if out == "" { + return "empty" + } + return out +} + +func toolResultPayload(res fantasy.ToolResultContent) (payload map[string]any, payloadType string, payloadText string) { + payloadType = "unknown" + payloadText = "" + + switch v := res.Result.(type) { + case fantasy.ToolResultOutputContentText: + payloadType = "text" + payloadText = v.Text + payload = map[string]any{ + "type": "text", + "text": v.Text, + } + case fantasy.ToolResultOutputContentMedia: + payloadType = "media" + payloadText = v.Text + payload = map[string]any{ + "type": "media", + "text": v.Text, + "media_type": v.MediaType, + "data": v.Data, + } + case fantasy.ToolResultOutputContentError: + payloadType = "error" + errS := "" + if v.Error != nil { + errS = v.Error.Error() + } + payloadText = errS + payload = map[string]any{ + "type": "error", + "error": errS, + } + default: + payload = map[string]any{ + "type": "unknown", + "value": fmt.Sprintf("%v", res.Result), + } + payloadText = fmt.Sprintf("%v", res.Result) + } + + payload["tool_call_id"] = res.ToolCallID + payload["tool_name"] = res.ToolName + payload["provider_executed"] = res.ProviderExecuted + payload["client_metadata"] = res.ClientMetadata + payload["provider_metadata"] = res.ProviderMetadata + + return payload, payloadType, payloadText +} + +func chunkString(s string, chunkSize int) []string { + if chunkSize <= 0 { + return []string{s} + } + r := []rune(s) + if len(r) == 0 { + return []string{""} + } + out := make([]string, 0, (len(r)/chunkSize)+1) + for i := 0; i < len(r); i += chunkSize { + end := i + chunkSize + if end > len(r) { + end = len(r) + } + out = append(out, string(r[i:end])) + } + return out +} + +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} diff --git a/pkg/agent/state_store.go b/pkg/agent/state_store.go new file mode 100644 index 000000000..2b5d85898 --- /dev/null +++ b/pkg/agent/state_store.go @@ -0,0 +1,195 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "time" + + "charm.land/fantasy" + "github.com/sipeed/picoclaw/pkg/ids" + "github.com/sipeed/picoclaw/pkg/memory/sqlc" + "github.com/sipeed/picoclaw/pkg/pcerrors" +) + +// StateStore persists agent run state snapshots and transition logs. +type StateStore struct { + q *sqlc.Queries +} + +func NewStateStore(q *sqlc.Queries) *StateStore { + return &StateStore{q: q} +} + +func (s *StateStore) CreateRun(ctx context.Context, conversationID ids.UUID) (sqlc.AgentRun, error) { + if s == nil || s.q == nil { + return sqlc.AgentRun{}, pcerrors.New(pcerrors.CodeUnknown, "state store is not configured") + } + if conversationID.IsZero() { + return sqlc.AgentRun{}, pcerrors.New(pcerrors.CodeUnknown, "conversation id is empty") + } + + return s.q.CreateAgentRun(ctx, sqlc.CreateAgentRunParams{ + ID: ids.New(), + ConversationID: conversationID, + Status: "running", + MetadataJson: json.RawMessage(`{}`), + }) +} + +func (s *StateStore) UpdateRunStatus(ctx context.Context, runID ids.UUID, status string, meta map[string]any) (sqlc.AgentRun, error) { + if s == nil || s.q == nil { + return sqlc.AgentRun{}, pcerrors.New(pcerrors.CodeUnknown, "state store is not configured") + } + if runID.IsZero() { + return sqlc.AgentRun{}, pcerrors.New(pcerrors.CodeUnknown, "run id is empty") + } + if status == "" { + return sqlc.AgentRun{}, pcerrors.New(pcerrors.CodeUnknown, "status is empty") + } + + metaJSON := json.RawMessage(`{}`) + if meta != nil { + if b, err := json.Marshal(meta); err == nil { + metaJSON = b + } + } + + return s.q.UpdateAgentRunStatus(ctx, sqlc.UpdateAgentRunStatusParams{ + Status: status, + MetadataJson: metaJSON, + ID: runID, + }) +} + +func (s *StateStore) AddRunState(ctx context.Context, runID ids.UUID, stepIndex int, state fantasy.ReActState, snapshot any) (sqlc.AgentRunState, error) { + if s == nil || s.q == nil { + return sqlc.AgentRunState{}, pcerrors.New(pcerrors.CodeUnknown, "state store is not configured") + } + if runID.IsZero() { + return sqlc.AgentRunState{}, pcerrors.New(pcerrors.CodeUnknown, "run id is empty") + } + if stepIndex < 0 { + return sqlc.AgentRunState{}, pcerrors.New(pcerrors.CodeUnknown, "step index is negative") + } + + snapJSON := json.RawMessage(`{}`) + if snapshot != nil { + if b, err := json.Marshal(snapshot); err == nil { + snapJSON = b + } + } + + return s.q.AddAgentRunState(ctx, sqlc.AddAgentRunStateParams{ + ID: ids.New(), + RunID: runID, + StepIndex: int64(stepIndex), + State: string(state), + SnapshotJson: snapJSON, + }) +} + +func (s *StateStore) AddTransition(ctx context.Context, runID ids.UUID, t fantasy.ReActTransition) (sqlc.AgentStateTransition, error) { + if s == nil || s.q == nil { + return sqlc.AgentStateTransition{}, pcerrors.New(pcerrors.CodeUnknown, "state store is not configured") + } + if runID.IsZero() { + return sqlc.AgentStateTransition{}, pcerrors.New(pcerrors.CodeUnknown, "run id is empty") + } + + metaJSON := json.RawMessage(`{}`) + if t.Meta != nil { + if b, err := json.Marshal(t.Meta); err == nil { + metaJSON = b + } + } + + var errPtr *string + if t.Error != "" { + errPtr = &t.Error + } + + at := t.At + if at.IsZero() { + at = time.Now().UTC() + } + + return s.q.AddAgentStateTransition(ctx, sqlc.AddAgentStateTransitionParams{ + ID: ids.New(), + RunID: runID, + StepIndex: int64(t.StepIndex), + FromState: string(t.From), + ToState: string(t.To), + Trigger: string(t.Trigger), + At: at, + MetaJson: metaJSON, + Error: errPtr, + }) +} + +// CheckpointStore persists named checkpoints for later restore. +type CheckpointStore struct { + q *sqlc.Queries +} + +func NewCheckpointStore(q *sqlc.Queries) *CheckpointStore { + return &CheckpointStore{q: q} +} + +func (s *CheckpointStore) CreateCheckpoint(ctx context.Context, conversationID ids.UUID, name string, runStateID ids.UUID, meta map[string]any) (sqlc.AgentCheckpoint, error) { + if s == nil || s.q == nil { + return sqlc.AgentCheckpoint{}, pcerrors.New(pcerrors.CodeUnknown, "checkpoint store is not configured") + } + if conversationID.IsZero() { + return sqlc.AgentCheckpoint{}, pcerrors.New(pcerrors.CodeUnknown, "conversation id is empty") + } + if strings.TrimSpace(name) == "" { + return sqlc.AgentCheckpoint{}, pcerrors.New(pcerrors.CodeUnknown, "checkpoint name is empty") + } + if runStateID.IsZero() { + return sqlc.AgentCheckpoint{}, pcerrors.New(pcerrors.CodeUnknown, "run state id is empty") + } + + metaJSON := json.RawMessage(`{}`) + if meta != nil { + if b, err := json.Marshal(meta); err == nil { + metaJSON = b + } + } + + return s.q.CreateAgentCheckpoint(ctx, sqlc.CreateAgentCheckpointParams{ + ID: ids.New(), + ConversationID: conversationID, + Name: strings.TrimSpace(name), + RunStateID: runStateID, + MetadataJson: metaJSON, + }) +} + +func (s *CheckpointStore) ListCheckpoints(ctx context.Context, conversationID ids.UUID) ([]sqlc.AgentCheckpoint, error) { + if s == nil || s.q == nil { + return nil, pcerrors.New(pcerrors.CodeUnknown, "checkpoint store is not configured") + } + if conversationID.IsZero() { + return nil, pcerrors.New(pcerrors.CodeUnknown, "conversation id is empty") + } + return s.q.ListAgentCheckpointsByConversationID(ctx, sqlc.ListAgentCheckpointsByConversationIDParams{ + ConversationID: conversationID, + }) +} + +func (s *CheckpointStore) GetCheckpoint(ctx context.Context, conversationID ids.UUID, name string) (sqlc.AgentCheckpoint, error) { + if s == nil || s.q == nil { + return sqlc.AgentCheckpoint{}, pcerrors.New(pcerrors.CodeUnknown, "checkpoint store is not configured") + } + if conversationID.IsZero() { + return sqlc.AgentCheckpoint{}, pcerrors.New(pcerrors.CodeUnknown, "conversation id is empty") + } + if strings.TrimSpace(name) == "" { + return sqlc.AgentCheckpoint{}, pcerrors.New(pcerrors.CodeUnknown, "checkpoint name is empty") + } + return s.q.GetAgentCheckpointByConversationIDAndName(ctx, sqlc.GetAgentCheckpointByConversationIDAndNameParams{ + ConversationID: conversationID, + Name: strings.TrimSpace(name), + }) +} diff --git a/pkg/agent/tool_result_search.go b/pkg/agent/tool_result_search.go new file mode 100644 index 000000000..08e88361b --- /dev/null +++ b/pkg/agent/tool_result_search.go @@ -0,0 +1,289 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "charm.land/fantasy" + "github.com/sipeed/picoclaw/pkg/ids" + "github.com/sipeed/picoclaw/pkg/memory/sqlc" + "github.com/sipeed/picoclaw/pkg/pcerrors" +) + +type ToolResultSearchView struct { + StartLine int `json:"start_line,omitempty" description:"Optional. 1-indexed start line (inclusive)."` + EndLine int `json:"end_line,omitempty" description:"Optional. 1-indexed end line (inclusive)."` + MaxLines int `json:"max_lines,omitempty" description:"Optional. Default 30, max 200."` + StartChunk int `json:"start_chunk,omitempty" description:"Optional. 0-indexed start chunk (inclusive)."` + EndChunk int `json:"end_chunk,omitempty" description:"Optional. 0-indexed end chunk (inclusive)."` + MaxChunks int `json:"max_chunks,omitempty" description:"Optional. Default 3, max 20."` +} + +type ToolResultSearchInput struct { + ConversationID string `json:"conversation_id,omitempty" description:"Optional. Agent conversation UUID."` + RunID string `json:"run_id,omitempty" description:"Optional. Agent run UUID."` + ToolCallID string `json:"tool_call_id,omitempty" description:"Optional. Tool call id to fetch (requires run_id)."` + ToolName string `json:"tool_name,omitempty" description:"Optional. Filter by tool name."` + Query string `json:"query,omitempty" description:"Optional. Case-insensitive substring match on tool_name/tool_call_id/summary."` + + Limit int `json:"limit,omitempty" description:"Optional. Default 5, max 50."` + View *ToolResultSearchView `json:"view,omitempty" description:"Optional. File view range for each result."` +} + +// NewToolResultSearchTool creates the tool_result_search agent tool for +// querying previously offloaded tool results. +func NewToolResultSearchTool(q *sqlc.Queries, kv KVDelegate) fantasy.AgentTool { + return fantasy.NewAgentTool( + "tool_result_search", + "Search previously stored tool results for this agent. Supports viewing a line range or chunk range from stored results.", + func(ctx context.Context, input ToolResultSearchInput, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + _ = call + + if q == nil { + return fantasy.NewTextErrorResponse("db is not configured"), nil + } + if kv == nil { + return fantasy.NewTextErrorResponse("KV delegate is not configured"), nil + } + + limit := input.Limit + if limit <= 0 { + limit = 5 + } + if limit > 50 { + limit = 50 + } + + rows, err := loadToolResultRows(ctx, q, input) + if err != nil { + return fantasy.NewTextErrorResponse(err.Error()), nil + } + + query := strings.ToLower(strings.TrimSpace(input.Query)) + toolName := strings.TrimSpace(input.ToolName) + + filtered := make([]sqlc.AgentToolResult, 0, len(rows)) + for _, r := range rows { + if toolName != "" && r.ToolName != toolName { + continue + } + if query != "" { + if !strings.Contains(strings.ToLower(r.ToolName), query) && + !strings.Contains(strings.ToLower(r.ToolCallID), query) && + (r.Preview == nil || !strings.Contains(strings.ToLower(*r.Preview), query)) { + continue + } + } + filtered = append(filtered, r) + if len(filtered) >= limit { + break + } + } + + view := input.View + startLine, endLine := normalizeLineView(view) + startChunk, endChunk := normalizeChunkView(view) + + type item struct { + ID string `json:"id"` + RunID string `json:"run_id"` + StepIndex int64 `json:"step_index"` + ToolCallID string `json:"tool_call_id"` + ToolName string `json:"tool_name"` + Preview *string `json:"preview,omitempty"` + FullKey string `json:"full_key"` + ChunkCount int64 `json:"chunk_count"` + View string `json:"view"` + ViewRange map[string]int `json:"view_range"` + Metadata json.RawMessage `json:"metadata_json"` + } + + out := struct { + Total int `json:"total"` + Items []item `json:"items"` + }{ + Total: len(filtered), + Items: make([]item, 0, len(filtered)), + } + + for _, r := range filtered { + sel, viewRange, loadErr := loadView(ctx, kv, r, startLine, endLine, startChunk, endChunk) + if loadErr != nil { + sel = "ERROR: " + loadErr.Error() + viewRange = map[string]int{ + "start_line": startLine, + "end_line": endLine, + "start_chunk": startChunk, + "end_chunk": endChunk, + } + } + + out.Items = append(out.Items, item{ + ID: r.ID.String(), + RunID: r.RunID.String(), + StepIndex: r.StepIndex, + ToolCallID: r.ToolCallID, + ToolName: r.ToolName, + Preview: r.Preview, + FullKey: r.FullKey, + ChunkCount: r.ChunkCount, + View: sel, + ViewRange: viewRange, + Metadata: r.MetadataJson, + }) + } + + b, _ := json.Marshal(out) + return fantasy.NewTextResponse(string(b)), nil + }, + ) +} + +func loadToolResultRows(ctx context.Context, q *sqlc.Queries, input ToolResultSearchInput) ([]sqlc.AgentToolResult, error) { + if q == nil { + return nil, pcerrors.New(pcerrors.CodeUnknown, "db is not configured") + } + + if strings.TrimSpace(input.RunID) != "" && strings.TrimSpace(input.ToolCallID) != "" { + runID, err := ids.Parse(strings.TrimSpace(input.RunID)) + if err != nil { + return nil, err + } + row, err := q.GetAgentToolResultByRunIDAndToolCallID(ctx, sqlc.GetAgentToolResultByRunIDAndToolCallIDParams{ + RunID: runID, + ToolCallID: strings.TrimSpace(input.ToolCallID), + }) + if err != nil { + return nil, err + } + return []sqlc.AgentToolResult{row}, nil + } + + if strings.TrimSpace(input.RunID) != "" { + runID, err := ids.Parse(strings.TrimSpace(input.RunID)) + if err != nil { + return nil, err + } + return q.ListAgentToolResultsByRunID(ctx, sqlc.ListAgentToolResultsByRunIDParams{ + RunID: runID, + }) + } + + if strings.TrimSpace(input.ConversationID) != "" { + conversationID, err := ids.Parse(strings.TrimSpace(input.ConversationID)) + if err != nil { + return nil, err + } + return q.ListAgentToolResultsByConversationID(ctx, sqlc.ListAgentToolResultsByConversationIDParams{ + ConversationID: conversationID, + }) + } + + return nil, pcerrors.New(pcerrors.CodeUnknown, "conversation_id or run_id is required (and tool_call_id requires run_id)") +} + +func normalizeLineView(v *ToolResultSearchView) (startLine int, endLine int) { + startLine = 1 + maxLines := 30 + + if v == nil { + return 1, 30 + } + if v.StartLine > 0 { + startLine = v.StartLine + } + if v.MaxLines > 0 { + maxLines = v.MaxLines + } + if maxLines > 200 { + maxLines = 200 + } + if v.EndLine > 0 { + endLine = v.EndLine + } else { + endLine = startLine + maxLines - 1 + } + if endLine < startLine { + endLine = startLine + } + return startLine, endLine +} + +func normalizeChunkView(v *ToolResultSearchView) (startChunk int, endChunk int) { + startChunk = 0 + maxChunks := 3 + + if v == nil { + return 0, 2 + } + if v.StartChunk > 0 { + startChunk = v.StartChunk + } + if v.MaxChunks > 0 { + maxChunks = v.MaxChunks + } + if maxChunks > 20 { + maxChunks = 20 + } + if v.EndChunk > 0 { + endChunk = v.EndChunk + } else { + endChunk = startChunk + maxChunks - 1 + } + if endChunk < startChunk { + endChunk = startChunk + } + return startChunk, endChunk +} + +func loadView(ctx context.Context, kv KVDelegate, row sqlc.AgentToolResult, startLine, endLine, startChunk, endChunk int) (string, map[string]int, error) { + if kv == nil { + return "", nil, pcerrors.New(pcerrors.CodeUnknown, "KV delegate is nil") + } + + if row.ChunkCount > 0 { + if startChunk < 0 { + startChunk = 0 + } + if int64(endChunk) >= row.ChunkCount { + endChunk = int(row.ChunkCount - 1) + } + if endChunk < startChunk { + endChunk = startChunk + } + + baseDir := strings.TrimSuffix(row.FullKey, "/full.json") + var b strings.Builder + for i := startChunk; i <= endChunk; i++ { + chunkKey := fmt.Sprintf("%s/chunks/%06d.txt", baseDir, i) + part, err := kv.Get(ctx, chunkKey) + if err != nil { + return "", nil, err + } + b.Write(part) + } + return b.String(), map[string]int{"start_chunk": startChunk, "end_chunk": endChunk}, nil + } + + raw, err := kv.Get(ctx, row.FullKey) + if err != nil { + return "", nil, err + } + lines := strings.Split(string(raw), "\n") + + sl := startLine + el := endLine + if sl < 1 { + sl = 1 + } + if el > len(lines) { + el = len(lines) + } + if el < sl { + el = sl + } + + return strings.Join(lines[sl-1:el], "\n"), map[string]int{"start_line": sl, "end_line": el}, nil +} diff --git a/pkg/pcerrors/cli.go b/pkg/pcerrors/cli.go new file mode 100644 index 000000000..f5aa60264 --- /dev/null +++ b/pkg/pcerrors/cli.go @@ -0,0 +1,54 @@ +package pcerrors + +import ( + "fmt" + "io" + "os" +) + +// CLIHandler is the PicoClaw error lifecycle boundary for the CLI. +// +// It is intentionally small: render a user-facing message and return an exit code. +// TODO: More advanced behaviors (structured logging, debug traces, redaction) can be layered on later. +type CLIHandler struct { + Writer io.Writer +} + +func DefaultCLIHandler() CLIHandler { + return CLIHandler{Writer: os.Stderr} +} + +func (h CLIHandler) Handle(err error) int { + if err == nil { + return 0 + } + _, _ = fmt.Fprintln(h.Writer, UserMessage(err)) + return ExitCode(err) +} + +// UserMessage returns a friendly, stable message for humans. +// +// For structured errors, we prefer the top-level message (not the fully formatted builder.Error()). +func UserMessage(err error) string { + if eb, ok := AsErrBuilder(err); ok && eb != nil { + if eb.Msg != "" { + return eb.Msg + } + } + return err.Error() +} + +// ExitCode maps error codes to process exit codes. +func ExitCode(err error) int { + if err == nil { + return 0 + } + switch CodeOf(err) { + case CodeInvalidArgument, CodeFailedPrecondition, CodeOutOfRange: + return 2 + case CodeUnauthenticated, CodePermissionDenied: + return 3 + default: + return 1 + } +} diff --git a/pkg/pcerrors/pcerrors.go b/pkg/pcerrors/pcerrors.go new file mode 100644 index 000000000..b1def407a --- /dev/null +++ b/pkg/pcerrors/pcerrors.go @@ -0,0 +1,174 @@ +package pcerrors + +import ( + "context" + "errors" + "fmt" + + assert "github.com/ZanzyTHEbar/assert-lib" + errbuilder "github.com/ZanzyTHEbar/errbuilder-go" +) + +// Code is the canonical PicoClaw error code type. +// +// We intentionally re-export errbuilder's gRPC-inspired code set so callers can +// classify errors without inventing ad-hoc sentinels. +type Code = errbuilder.ErrCode + +const ( + CodeCanceled = errbuilder.CodeCanceled + CodeUnknown = errbuilder.CodeUnknown + CodeInvalidArgument = errbuilder.CodeInvalidArgument + CodeDeadlineExceeded = errbuilder.CodeDeadlineExceeded + CodeNotFound = errbuilder.CodeNotFound + CodeAlreadyExists = errbuilder.CodeAlreadyExists + CodePermissionDenied = errbuilder.CodePermissionDenied + CodeResourceExhausted = errbuilder.CodeResourceExhausted + CodeFailedPrecondition = errbuilder.CodeFailedPrecondition + CodeAborted = errbuilder.CodeAborted + CodeOutOfRange = errbuilder.CodeOutOfRange + CodeUnimplemented = errbuilder.CodeUnimplemented + CodeInternal = errbuilder.CodeInternal + CodeUnavailable = errbuilder.CodeUnavailable + CodeDataLoss = errbuilder.CodeDataLoss + CodeUnauthenticated = errbuilder.CodeUnauthenticated +) + +// ErrMap is a key->error bag for validation-style error details. +type ErrMap = errbuilder.ErrorMap + +// Option configures an ErrBuilder before it is returned as an error. +type Option func(*buildOptions) + +type buildOptions struct { + label string + cause error + details ErrMap +} + +func WithLabel(label string) Option { + return func(o *buildOptions) { o.label = label } +} + +func WithCause(err error) Option { + return func(o *buildOptions) { o.cause = err } +} + +// WithDetail sets a single key/value detail. +// +// msg must be a string or error; other types panic (this matches errbuilder.ErrorMap.Set). +func WithDetail(key string, msg any) Option { + return func(o *buildOptions) { + o.details.Set(key, msg) + } +} + +// WithDetails merges an entire error map into the error details. +func WithDetails(m ErrMap) Option { + return func(o *buildOptions) { + if m == nil { + return + } + if o.details == nil { + o.details = make(ErrMap, len(m)) + } + for k, v := range m { + o.details[k] = v + } + } +} + +// New constructs a structured PicoClaw error. +// +// This returns an *errbuilder.ErrBuilder which: +// - implements error +// - supports Unwrap() so stdlib errors.Is/errors.As continue to work +// - carries a Code for classification. +func New(code Code, msg string, opts ...Option) error { + o := buildOptions{} + for _, opt := range opts { + opt(&o) + } + + b := errbuilder.New().WithCode(code).WithMsg(msg) + if o.label != "" { + b = b.WithLabel(o.label) + } + if o.cause != nil { + // Ensure context cancellation/deadline get codes if they weren't wrapped already. + b = b.WithCause(errbuilder.WrapIfContextError(o.cause)) + } + if o.details != nil { + b = b.WithDetails(errbuilder.NewErrDetails(o.details)) + } + return b +} + +// Newf is like New but formats the message via fmt.Sprintf. +// +// Callers use this instead of fmt.Errorf when they want formatting +// without creating a second wrapping error layer. +func Newf(code Code, format string, args ...any) error { + return New(code, fmt.Sprintf(format, args...)) +} + +// Wrap wraps err with a new structured error code and message. +func Wrap(code Code, err error, msg string, opts ...Option) error { + if err == nil { + return nil + } + return New(code, msg, append(opts, WithCause(err))...) +} + +// Wrapf wraps err with a formatted message. +func Wrapf(code Code, err error, format string, args ...any) error { + return Wrap(code, err, fmt.Sprintf(format, args...)) +} + +// CodeOf returns the structured code for err if it is (or wraps) an ErrBuilder. +// Otherwise it returns CodeUnknown. +func CodeOf(err error) Code { + return errbuilder.CodeOf(err) +} + +// Is is a thin wrapper over errors.Is. +func Is(err error, target error) bool { + return errors.Is(err, target) +} + +// As is a type-safe wrapper over errors.As. +// +// Go's errors.As will panic if the provided target is not a pointer to an +// interface or a type implementing error. +// +// By constraining T to error, we eliminate the most common foot-gun at compile time. +func As[T error](err error) (T, bool) { + var target T + if err == nil { + return target, false + } + if errors.As(err, &target) { + return target, true + } + return target, false +} + +// AsInto mirrors errors.As but keeps the type safety of T error. +func AsInto[T error](err error, target *T) bool { + if err == nil || target == nil { + return false + } + return errors.As(err, target) +} + +// AsErrBuilder extracts an underlying *errbuilder.ErrBuilder if present. +func AsErrBuilder(err error) (*errbuilder.ErrBuilder, bool) { + return As[*errbuilder.ErrBuilder](err) +} + +// Assert integrates assert-lib so callers can opt into lightweight runtime checks. +// +// We default to production-friendly formatting, and we keep the library's safe-by-default behavior. +func Assert(ctx context.Context, condition bool, msg string, opts ...assert.Option) { + assert.Assert(ctx, condition, msg, append([]assert.Option{assert.WithProductionDefaults()}, opts...)...) +} diff --git a/pkg/security/jsonextract.go b/pkg/security/jsonextract.go new file mode 100644 index 000000000..34bff40b1 --- /dev/null +++ b/pkg/security/jsonextract.go @@ -0,0 +1,259 @@ +// Package security provides hardened input handling for LLM-generated content. +package security + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +// ErrNoJSON is returned when no valid JSON object is found in the input. +var ErrNoJSON = errors.New("no valid JSON object found in text") + +// ErrInputTooLarge is returned when the input exceeds the maximum allowed size. +var ErrInputTooLarge = errors.New("input exceeds maximum allowed size") + +const ( + defaultMaxInputBytes = 64 * 1024 // 64KB +) + +// ExtractJSONOptions configures JSON extraction behavior. +type ExtractJSONOptions struct { + MaxInputBytes int // Maximum input size in bytes. Default: 64KB. + DisallowUnknownFields bool // Reject JSON with keys not in the target struct. +} + +// ExtractJSON extracts the first valid JSON object from LLM-generated text and +// unmarshals it into dest. It handles common LLM output patterns: +// - Raw JSON +// - JSON wrapped in ```json ... ``` code fences +// - JSON embedded in prose text +// +// It applies size limits and optionally rejects unknown fields, defending +// against prompt injection attacks that attempt to smuggle extra keys. +func ExtractJSON(text string, dest interface{}, opts *ExtractJSONOptions) error { + if opts == nil { + opts = &ExtractJSONOptions{} + } + maxBytes := opts.MaxInputBytes + if maxBytes <= 0 { + maxBytes = defaultMaxInputBytes + } + + if len(text) > maxBytes { + return fmt.Errorf("%w: %d bytes (max %d)", ErrInputTooLarge, len(text), maxBytes) + } + + cleaned := extractJSONString(text) + if cleaned == "" { + return ErrNoJSON + } + + dec := json.NewDecoder(strings.NewReader(cleaned)) + if opts.DisallowUnknownFields { + dec.DisallowUnknownFields() + } + + if err := dec.Decode(dest); err != nil { + return fmt.Errorf("json decode: %w", err) + } + return nil +} + +// extractJSONString isolates the JSON object from surrounding text. +// Strategy (in priority order): +// 1. Try the text as-is (after trim) +// 2. Extract from ```json ... ``` code fence +// 3. Find the first { ... } balanced brace pair +func extractJSONString(text string) string { + text = strings.TrimSpace(text) + + if isJSON(text) { + return text + } + + if extracted := extractFromCodeFence(text); extracted != "" && isJSON(extracted) { + return extracted + } + + if extracted := extractFirstBraced(text); extracted != "" && isJSON(extracted) { + return extracted + } + + return "" +} + +func isJSON(s string) bool { + s = strings.TrimSpace(s) + if len(s) < 2 { + return false + } + return (s[0] == '{' && s[len(s)-1] == '}') || (s[0] == '[' && s[len(s)-1] == ']') +} + +// extractFromCodeFence extracts content from the first ```json ... ``` fence. +func extractFromCodeFence(text string) string { + lower := strings.ToLower(text) + + markers := []string{"```json", "```"} + for _, marker := range markers { + idx := strings.Index(lower, marker) + if idx < 0 { + continue + } + start := idx + len(marker) + rest := text[start:] + endIdx := strings.Index(rest, "```") + if endIdx < 0 { + continue + } + return strings.TrimSpace(rest[:endIdx]) + } + return "" +} + +// extractFirstBraced finds the first balanced { ... } in text. +// Handles nested braces and string literals containing braces. +func extractFirstBraced(text string) string { + start := strings.IndexByte(text, '{') + if start < 0 { + return "" + } + + depth := 0 + inString := false + escaped := false + + for i := start; i < len(text); i++ { + ch := text[i] + if escaped { + escaped = false + continue + } + if ch == '\\' && inString { + escaped = true + continue + } + if ch == '"' { + inString = !inString + continue + } + if inString { + continue + } + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return text[start : i+1] + } + } + } + return "" +} + +// SanitizeToolArgs validates that tool arguments conform to expected types and +// constraints. It returns the sanitized arguments or an error. +func SanitizeToolArgs(args map[string]interface{}, schema map[string]ArgSpec) (map[string]interface{}, error) { + sanitized := make(map[string]interface{}, len(schema)) + + for name, spec := range schema { + val, exists := args[name] + if !exists || val == nil { + if spec.Required { + return nil, fmt.Errorf("missing required argument: %s", name) + } + if spec.Default != nil { + sanitized[name] = spec.Default + } + continue + } + + coerced, err := coerceArg(val, spec.Type) + if err != nil { + return nil, fmt.Errorf("argument %q: %w", name, err) + } + + if spec.MaxLength > 0 { + if s, ok := coerced.(string); ok && len(s) > spec.MaxLength { + return nil, fmt.Errorf("argument %q exceeds max length %d", name, spec.MaxLength) + } + } + + sanitized[name] = coerced + } + + return sanitized, nil +} + +// ArgSpec defines validation constraints for a single tool argument. +type ArgSpec struct { + Type ArgType // Expected type. + Required bool // Whether the argument is required. + MaxLength int // Maximum string length (0 = unlimited). + Default interface{} // Default value if not provided. +} + +// ArgType represents the expected type of a tool argument. +type ArgType int + +const ( + ArgString ArgType = iota + ArgInt + ArgFloat + ArgBool + ArgObject + ArgArray +) + +func coerceArg(val interface{}, expected ArgType) (interface{}, error) { + switch expected { + case ArgString: + switch v := val.(type) { + case string: + return v, nil + case float64: + return fmt.Sprintf("%g", v), nil + default: + return nil, fmt.Errorf("expected string, got %T", val) + } + case ArgInt: + switch v := val.(type) { + case float64: + return int(v), nil + case int: + return v, nil + default: + return nil, fmt.Errorf("expected integer, got %T", val) + } + case ArgFloat: + switch v := val.(type) { + case float64: + return v, nil + case int: + return float64(v), nil + default: + return nil, fmt.Errorf("expected number, got %T", val) + } + case ArgBool: + if b, ok := val.(bool); ok { + return b, nil + } + return nil, fmt.Errorf("expected boolean, got %T", val) + case ArgObject: + if m, ok := val.(map[string]interface{}); ok { + return m, nil + } + return nil, fmt.Errorf("expected object, got %T", val) + case ArgArray: + if a, ok := val.([]interface{}); ok { + return a, nil + } + return nil, fmt.Errorf("expected array, got %T", val) + default: + return val, nil + } +} diff --git a/pkg/security/jsonextract_test.go b/pkg/security/jsonextract_test.go new file mode 100644 index 000000000..1b27fd2f5 --- /dev/null +++ b/pkg/security/jsonextract_test.go @@ -0,0 +1,232 @@ +package security + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractJSON_RawJSON(t *testing.T) { + tests := []struct { + name string + input string + wantKey string + wantVal interface{} + }{ + {"clean object", `{"importance": 0.8}`, "importance", 0.8}, + {"with whitespace", ` {"key": "val"} `, "key", "val"}, + {"nested", `{"outer": {"inner": true}}`, "outer", map[string]interface{}{"inner": true}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var result map[string]interface{} + err := ExtractJSON(tc.input, &result, nil) + require.NoError(t, err) + assert.Equal(t, tc.wantVal, result[tc.wantKey]) + }) + } +} + +func TestExtractJSON_CodeFence(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + "json fence", + "Here is the result:\n```json\n{\"score\": 42}\n```\n", + }, + { + "plain fence", + "```\n{\"score\": 42}\n```", + }, + { + "fence with prose before and after", + "I analyzed the data.\n```json\n{\"score\": 42}\n```\nDone!", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var result map[string]interface{} + err := ExtractJSON(tc.input, &result, nil) + require.NoError(t, err) + assert.Equal(t, float64(42), result["score"]) + }) + } +} + +func TestExtractJSON_EmbeddedInProse(t *testing.T) { + input := `Based on my analysis, the result is {"importance": 0.9, "sector": "semantic"} which indicates high relevance.` + var result struct { + Importance float64 `json:"importance"` + Sector string `json:"sector"` + } + err := ExtractJSON(input, &result, nil) + require.NoError(t, err) + assert.Equal(t, 0.9, result.Importance) + assert.Equal(t, "semantic", result.Sector) +} + +func TestExtractJSON_NestedBracesInStrings(t *testing.T) { + input := `{"content": "function() { return {}; }", "count": 1}` + var result map[string]interface{} + err := ExtractJSON(input, &result, nil) + require.NoError(t, err) + assert.Equal(t, "function() { return {}; }", result["content"]) + assert.Equal(t, float64(1), result["count"]) +} + +func TestExtractJSON_InjectionAttempts(t *testing.T) { + tests := []struct { + name string + input string + opts *ExtractJSONOptions + check func(t *testing.T, err error) + }{ + { + "oversized input", + strings.Repeat("x", 100*1024), + nil, + func(t *testing.T, err error) { + assert.ErrorIs(t, err, ErrInputTooLarge) + }, + }, + { + "no json at all", + "This is just prose with no JSON.", + nil, + func(t *testing.T, err error) { + assert.ErrorIs(t, err, ErrNoJSON) + }, + }, + { + "unknown fields rejected", + `{"importance": 0.5, "injected_key": "malicious"}`, + &ExtractJSONOptions{DisallowUnknownFields: true}, + func(t *testing.T, err error) { + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown field") + }, + }, + { + "custom size limit", + `{"key": "val"}`, + &ExtractJSONOptions{MaxInputBytes: 5}, + func(t *testing.T, err error) { + assert.ErrorIs(t, err, ErrInputTooLarge) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var result struct { + Importance float64 `json:"importance"` + } + err := ExtractJSON(tc.input, &result, tc.opts) + tc.check(t, err) + }) + } +} + +func TestExtractJSON_EmptyInput(t *testing.T) { + var result map[string]interface{} + err := ExtractJSON("", &result, nil) + assert.ErrorIs(t, err, ErrNoJSON) +} + +func TestExtractJSON_MultipleFences_TakesFirst(t *testing.T) { + input := "```json\n{\"first\": true}\n```\nmore text\n```json\n{\"second\": true}\n```" + var result map[string]interface{} + err := ExtractJSON(input, &result, nil) + require.NoError(t, err) + assert.Equal(t, true, result["first"]) + _, hasSecond := result["second"] + assert.False(t, hasSecond) +} + +func TestSanitizeToolArgs_ValidInput(t *testing.T) { + schema := map[string]ArgSpec{ + "path": {Type: ArgString, Required: true, MaxLength: 256}, + "content": {Type: ArgString, Required: true}, + "mode": {Type: ArgString, Required: false, Default: "overwrite"}, + } + + args := map[string]interface{}{ + "path": "/tmp/test.txt", + "content": "hello world", + } + + result, err := SanitizeToolArgs(args, schema) + require.NoError(t, err) + assert.Equal(t, "/tmp/test.txt", result["path"]) + assert.Equal(t, "hello world", result["content"]) + assert.Equal(t, "overwrite", result["mode"]) +} + +func TestSanitizeToolArgs_MissingRequired(t *testing.T) { + schema := map[string]ArgSpec{ + "path": {Type: ArgString, Required: true}, + } + + _, err := SanitizeToolArgs(map[string]interface{}{}, schema) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required argument") +} + +func TestSanitizeToolArgs_ExceedsMaxLength(t *testing.T) { + schema := map[string]ArgSpec{ + "cmd": {Type: ArgString, Required: true, MaxLength: 10}, + } + + _, err := SanitizeToolArgs(map[string]interface{}{"cmd": "very long command string"}, schema) + assert.Error(t, err) + assert.Contains(t, err.Error(), "max length") +} + +func TestSanitizeToolArgs_TypeCoercion(t *testing.T) { + tests := []struct { + name string + argType ArgType + input interface{} + expected interface{} + wantErr bool + }{ + {"string from string", ArgString, "hello", "hello", false}, + {"string from float", ArgString, float64(42), "42", false}, + {"int from float", ArgInt, float64(42), 42, false}, + {"float from int", ArgFloat, 42, float64(42), false}, + {"bool valid", ArgBool, true, true, false}, + {"bool invalid", ArgBool, "true", nil, true}, + {"object valid", ArgObject, map[string]interface{}{"k": "v"}, map[string]interface{}{"k": "v"}, false}, + {"array valid", ArgArray, []interface{}{"a"}, []interface{}{"a"}, false}, + {"array invalid", ArgArray, "not array", nil, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + schema := map[string]ArgSpec{ + "arg": {Type: tc.argType, Required: true}, + } + result, err := SanitizeToolArgs(map[string]interface{}{"arg": tc.input}, schema) + if tc.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, result["arg"]) + } + }) + } +} + +func TestExtractFirstBraced_EscapedQuotes(t *testing.T) { + input := `{"msg": "say \"hello\" world"}` + result := extractFirstBraced(input) + assert.Equal(t, input, result) +} + +func TestExtractFirstBraced_UnbalancedBraces(t *testing.T) { + input := `text { not closed` + result := extractFirstBraced(input) + assert.Empty(t, result) +} diff --git a/pkg/security/redact.go b/pkg/security/redact.go new file mode 100644 index 000000000..3f7819597 --- /dev/null +++ b/pkg/security/redact.go @@ -0,0 +1,123 @@ +package security + +import ( + "regexp" + "strings" +) + +// Redactor strips sensitive patterns from text before it reaches logs or storage. +type Redactor struct { + patterns []*redactPattern +} + +type redactPattern struct { + re *regexp.Regexp + label string +} + +// NewRedactor builds a Redactor with the default set of PII/secret patterns. +func NewRedactor() *Redactor { + return &Redactor{patterns: defaultPatterns()} +} + +// Redact replaces all matched secrets/PII in text with [REDACTED: