From 58dec338a5e19966142cb3c815267a1ea34a9a7d Mon Sep 17 00:00:00 2001 From: RabbITCybErSeC Date: Fri, 22 May 2026 17:03:09 +0200 Subject: [PATCH 1/3] improve ebpf monitoring logging --- README.md | 4 +- cmd/policy-ebpfd/attach.go | 120 ++++++++++++- cmd/policy-ebpfd/attach_test.go | 29 +++- cmd/policy-ebpfd/command_monitor.go | 77 ++++---- cmd/policy-ebpfd/events.go | 123 +++++-------- cmd/policy-ebpfd/events_test.go | 23 ++- cmd/policy-ebpfd/main.go | 115 +++++++++--- cmd/policy-ebpfd/network_events.go | 148 ++++++++++++++++ cmd/policy-proxy/main.go | 110 ++++++++++-- cmd/policy-proxy/main_test.go | 108 +++++++++++- docs/implementation-spec.md | 2 +- docs/network-policy.md | 15 +- docs/quickstart.md | 10 +- docs/security-model.md | 2 +- docs/troubleshooting.md | 2 +- internal/audit/audit.go | 261 ++++++++++++++++++++++++++++ internal/audit/audit_test.go | 102 +++++++++++ internal/cli/run.go | 5 +- internal/config/config.go | 18 +- internal/config/defaults.go | 6 + internal/config/load.go | 26 +++ internal/config/load_test.go | 46 +++++ internal/config/validate.go | 6 + internal/doctor/checks.go | 81 +++++++++ internal/doctor/checks_test.go | 53 ++++++ internal/doctor/report.go | 2 +- internal/integration/live_test.go | 6 +- internal/runtime/events.go | 13 +- internal/runtime/events_test.go | 10 ++ internal/runtime/policy.go | 27 ++- internal/runtime/policy_test.go | 24 ++- 31 files changed, 1370 insertions(+), 204 deletions(-) create mode 100644 cmd/policy-ebpfd/network_events.go create mode 100644 internal/audit/audit.go create mode 100644 internal/audit/audit_test.go diff --git a/README.md b/README.md index c3267a2..53b1988 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,10 @@ For a fuller setup guide, including local CLI build, config paths, skill import, ## Command Audit Logging -Command audit logging is opt-in while the custom init image path is experimental. When enabled, the init daemon attaches eBPF exec tracepoints inside the Apple container Linux VM and writes every observed process exec to: +Command audit logging is opt-in while the custom init image path is experimental. When enabled, the init daemon attaches eBPF exec tracepoints inside the Apple container Linux VM and writes observed process exec events to the unified audit log: ```text -~/.local/state/opencode-sandbox/runs//command-events.jsonl +~/.local/state/opencode-sandbox/runs//audit-events.jsonl ``` The log includes full argv by default, so it may contain URLs, prompts, tokens, or other command-line secrets. If a run hangs at Apple container startup and the boot log mentions `/sbin/vminitd`, disable command audit and use practical proxy mode until the init image is rebuilt. diff --git a/cmd/policy-ebpfd/attach.go b/cmd/policy-ebpfd/attach.go index 2594810..e832e12 100644 --- a/cmd/policy-ebpfd/attach.go +++ b/cmd/policy-ebpfd/attach.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "fmt" "net" + "sync" "github.com/cilium/ebpf" "github.com/cilium/ebpf/asm" @@ -16,7 +17,11 @@ type enforcementHandle struct { settings *ebpf.Map blockedIPv4 *ebpf.Map allowedIPv4 *ebpf.Map + events *ebpf.Map defaultAction uint32 + mu sync.Mutex + nextRuleID uint32 + rules map[uint32]string } func (h *enforcementHandle) Close() error { @@ -52,6 +57,12 @@ func (h *enforcementHandle) Close() error { } return nil }, + func() error { + if h.events != nil { + return h.events.Close() + } + return nil + }, } { if err := closeFn(); err != nil && firstErr == nil { firstErr = err @@ -65,7 +76,7 @@ func (h *enforcementHandle) UpdateBlocked(ip net.IP, rule string) error { if !ok { return nil } - value := uint32(1) + value := h.ruleID(rule) return h.blockedIPv4.Update(key, value, ebpf.UpdateAny) } @@ -74,7 +85,7 @@ func (h *enforcementHandle) UpdateAllowed(ip net.IP, rule string) error { if !ok { return nil } - value := uint32(1) + value := h.ruleID(rule) return h.allowedIPv4.Update(key, value, ebpf.UpdateAny) } @@ -94,6 +105,26 @@ func (h *enforcementHandle) RemoveAllowed(ip net.IP) error { return h.allowedIPv4.Delete(key) } +func (h *enforcementHandle) ruleID(rule string) uint32 { + h.mu.Lock() + defer h.mu.Unlock() + if h.rules == nil { + h.rules = map[uint32]string{} + } + h.nextRuleID++ + if h.nextRuleID == 0 { + h.nextRuleID = 1 + } + h.rules[h.nextRuleID] = rule + return h.nextRuleID +} + +func (h *enforcementHandle) ruleName(id uint32) string { + h.mu.Lock() + defer h.mu.Unlock() + return h.rules[id] +} + // attachCgroupConnect loads an IPv4 cgroup/connect eBPF program and attaches // it for the daemon lifetime. The program uses exact-IP maps that are updated // by the resolver. Allowlist wins, then blocklist, then defaultAction. @@ -134,6 +165,17 @@ func attachCgroupConnect(cgroupPath string, bundle *PolicyBundle) (*enforcementH return nil, fmt.Errorf("creating allowed IPv4 map: %w", err) } + events, err := ebpf.NewMap(&ebpf.MapSpec{ + Type: ebpf.PerfEventArray, + Name: "network_events", + }) + if err != nil { + settings.Close() + blockedIPv4.Close() + allowedIPv4.Close() + return nil, fmt.Errorf("creating network event map: %w", err) + } + defaultAction := uint32(1) if bundle.Network.DefaultAction == "deny" || bundle.Network.Mode == "off" { defaultAction = 0 @@ -143,6 +185,7 @@ func attachCgroupConnect(cgroupPath string, bundle *PolicyBundle) (*enforcementH settings.Close() blockedIPv4.Close() allowedIPv4.Close() + events.Close() return nil, fmt.Errorf("initializing settings map: %w", err) } @@ -151,20 +194,30 @@ func attachCgroupConnect(cgroupPath string, bundle *PolicyBundle) (*enforcementH settings.Close() blockedIPv4.Close() allowedIPv4.Close() + events.Close() return nil, fmt.Errorf("associating settings map: %w", err) } if err := insns.AssociateMap("blocked_ipv4", blockedIPv4); err != nil { settings.Close() blockedIPv4.Close() allowedIPv4.Close() + events.Close() return nil, fmt.Errorf("associating blocked map: %w", err) } if err := insns.AssociateMap("allowed_ipv4", allowedIPv4); err != nil { settings.Close() blockedIPv4.Close() allowedIPv4.Close() + events.Close() return nil, fmt.Errorf("associating allowed map: %w", err) } + if err := insns.AssociateMap("network_events", events); err != nil { + settings.Close() + blockedIPv4.Close() + allowedIPv4.Close() + events.Close() + return nil, fmt.Errorf("associating network event map: %w", err) + } progSpec := &ebpf.ProgramSpec{ Name: "cgroup_connect4", @@ -178,6 +231,7 @@ func attachCgroupConnect(cgroupPath string, bundle *PolicyBundle) (*enforcementH settings.Close() blockedIPv4.Close() allowedIPv4.Close() + events.Close() return nil, fmt.Errorf("loading cgroup/connect program: %w", err) } @@ -191,6 +245,7 @@ func attachCgroupConnect(cgroupPath string, bundle *PolicyBundle) (*enforcementH settings.Close() blockedIPv4.Close() allowedIPv4.Close() + events.Close() return nil, fmt.Errorf("attaching cgroup/connect: %w", err) } @@ -200,12 +255,15 @@ func attachCgroupConnect(cgroupPath string, bundle *PolicyBundle) (*enforcementH settings: settings, blockedIPv4: blockedIPv4, allowedIPv4: allowedIPv4, + events: events, defaultAction: defaultAction, + rules: map[uint32]string{}, }, nil } func cgroupConnect4Instructions() asm.Instructions { - return asm.Instructions{ + insns := asm.Instructions{ + asm.Mov.Reg(asm.R9, asm.R1), asm.LoadMem(asm.R6, asm.R1, 4, asm.Word), asm.StoreMem(asm.RFP, -4, asm.R6, asm.Word), @@ -213,27 +271,75 @@ func cgroupConnect4Instructions() asm.Instructions { asm.Mov.Reg(asm.R2, asm.RFP), asm.Add.Imm(asm.R2, -4), asm.FnMapLookupElem.Call(), - asm.JNE.Imm(asm.R0, 0, "allow"), + asm.JEq.Imm(asm.R0, 0, "check_blocked"), + asm.LoadMem(asm.R7, asm.R0, 0, asm.Word), + asm.Mov.Imm(asm.R8, 1), + asm.Ja.Label("allow"), + asm.Mov.Imm(asm.R0, 0).WithSymbol("check_blocked"), asm.LoadMapPtr(asm.R1, 0).WithReference("blocked_ipv4"), asm.Mov.Reg(asm.R2, asm.RFP), asm.Add.Imm(asm.R2, -4), asm.FnMapLookupElem.Call(), - asm.JNE.Imm(asm.R0, 0, "deny"), + asm.JEq.Imm(asm.R0, 0, "check_default"), + asm.LoadMem(asm.R7, asm.R0, 0, asm.Word), + asm.Mov.Imm(asm.R8, 2), + asm.Ja.Label("deny"), + asm.Mov.Imm(asm.R0, 0).WithSymbol("check_default"), asm.StoreImm(asm.RFP, -8, 0, asm.Word), asm.LoadMapPtr(asm.R1, 0).WithReference("settings"), asm.Mov.Reg(asm.R2, asm.RFP), asm.Add.Imm(asm.R2, -8), asm.FnMapLookupElem.Call(), - asm.JEq.Imm(asm.R0, 0, "deny"), + asm.JEq.Imm(asm.R0, 0, "default_deny"), asm.LoadMem(asm.R0, asm.R0, 0, asm.Word), - asm.JNE.Imm(asm.R0, 0, "allow"), + asm.JEq.Imm(asm.R0, 0, "default_deny"), + asm.Mov.Imm(asm.R7, 0), + asm.Mov.Imm(asm.R8, 3), + asm.Ja.Label("allow"), + + asm.Mov.Imm(asm.R7, 0).WithSymbol("default_deny"), + asm.Mov.Imm(asm.R8, 4), + asm.Ja.Label("deny"), asm.Mov.Imm(asm.R0, 0).WithSymbol("deny"), + } + insns = append(insns, networkEventOutputInstructions(0)...) + insns = append(insns, asm.Return(), asm.Mov.Imm(asm.R0, 1).WithSymbol("allow"), + ) + insns = append(insns, networkEventOutputInstructions(1)...) + insns = append(insns, asm.Return(), + ) + return insns +} + +func networkEventOutputInstructions(decision int32) asm.Instructions { + return asm.Instructions{ + asm.StoreMem(asm.RFP, -32, asm.R6, asm.Word), + asm.FnGetCurrentPidTgid.Call(), + asm.RSh.Imm(asm.R0, 32), + asm.StoreMem(asm.RFP, -36, asm.R0, asm.Word), + asm.LoadMem(asm.R0, asm.R9, 20, asm.Word), + asm.StoreMem(asm.RFP, -28, asm.R0, asm.Word), + asm.Mov.Imm(asm.R0, decision), + asm.StoreMem(asm.RFP, -24, asm.R0, asm.Word), + asm.StoreMem(asm.RFP, -20, asm.R8, asm.Word), + asm.StoreMem(asm.RFP, -16, asm.R7, asm.Word), + asm.Mov.Imm(asm.R0, 0), + asm.StoreMem(asm.RFP, -12, asm.R0, asm.Word), + asm.StoreMem(asm.RFP, -8, asm.R0, asm.Word), + asm.Mov.Reg(asm.R1, asm.R9), + asm.LoadMapPtr(asm.R2, 0).WithReference("network_events"), + asm.LoadImm(asm.R3, 0xffffffff, asm.DWord), + asm.Mov.Reg(asm.R4, asm.RFP), + asm.Add.Imm(asm.R4, -36), + asm.Mov.Imm(asm.R5, 28), + asm.FnPerfEventOutput.Call(), + asm.Mov.Imm(asm.R0, decision), } } diff --git a/cmd/policy-ebpfd/attach_test.go b/cmd/policy-ebpfd/attach_test.go index 7aa6109..bc8b3c3 100644 --- a/cmd/policy-ebpfd/attach_test.go +++ b/cmd/policy-ebpfd/attach_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/binary" "net" "testing" ) @@ -33,12 +34,12 @@ func TestCgroupConnect4InstructionsReferenceMaps(t *testing.T) { symbols[sym] = true } } - for _, want := range []string{"allowed_ipv4", "blocked_ipv4", "settings"} { + for _, want := range []string{"allowed_ipv4", "blocked_ipv4", "settings", "network_events"} { if !refs[want] { t.Fatalf("expected map reference %q in instructions", want) } } - for _, want := range []string{"allow", "deny"} { + for _, want := range []string{"allow", "deny", "check_blocked", "check_default", "default_deny"} { if !symbols[want] { t.Fatalf("expected symbol %q in instructions", want) } @@ -47,3 +48,27 @@ func TestCgroupConnect4InstructionsReferenceMaps(t *testing.T) { t.Fatalf("expected instruction symbols to resolve: %v", err) } } + +func TestNetworkEventFromSampleResolvesDecisionAndRule(t *testing.T) { + bundle := &PolicyBundle{RunID: "run-1"} + bundle.Project.Name = "proj" + handle := &enforcementHandle{rules: map[uint32]string{7: "*.example.com"}} + raw := make([]byte, 28) + binary.LittleEndian.PutUint32(raw[0:4], 42) + binary.LittleEndian.PutUint32(raw[4:8], 0xcb00710a) + binary.LittleEndian.PutUint32(raw[8:12], 443) + binary.LittleEndian.PutUint32(raw[12:16], 0) + binary.LittleEndian.PutUint32(raw[16:20], networkReasonBlocklist) + binary.LittleEndian.PutUint32(raw[20:24], 7) + + event, err := networkEventFromSample(raw, bundle, handle) + if err != nil { + t.Fatalf("networkEventFromSample failed: %v", err) + } + if event.EventType != "network.connect" || event.Decision != "block" || event.Reason != "blocklist" { + t.Fatalf("unexpected event: %+v", event) + } + if event.DstIP != "203.0.113.10" || event.DstPort != 443 || event.MatchedRule != "*.example.com" { + t.Fatalf("unexpected destination/rule: %+v", event) + } +} diff --git a/cmd/policy-ebpfd/command_monitor.go b/cmd/policy-ebpfd/command_monitor.go index 8118fc9..e102969 100644 --- a/cmd/policy-ebpfd/command_monitor.go +++ b/cmd/policy-ebpfd/command_monitor.go @@ -12,6 +12,7 @@ import ( "strings" "sync" + auditlog "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" "github.com/cilium/ebpf" "github.com/cilium/ebpf/asm" "github.com/cilium/ebpf/link" @@ -20,11 +21,13 @@ import ( ) const ( - commandHookExecve = "execve" - commandHookExecveat = "execveat" + commandHookExecve = "execve" + commandHookExecveat = "execveat" + commandHookSchedExec = "sched_process_exec" - commandSyscallExecve = uint32(1) - commandSyscallExecveat = uint32(2) + commandSyscallExecve = uint32(1) + commandSyscallExecveat = uint32(2) + commandSyscallSchedExec = uint32(3) ) type bpfCommandEvent struct { @@ -36,17 +39,18 @@ type bpfCommandEvent struct { // CommandMonitor owns eBPF exec tracepoints and the userspace event loop. type CommandMonitor struct { - cancel context.CancelFunc - wg sync.WaitGroup - rd *perf.Reader - links []link.Link - progs []*ebpf.Program - events *ebpf.Map - writer *DaemonEventWriter + cancel context.CancelFunc + wg sync.WaitGroup + rd *perf.Reader + links []link.Link + progs []*ebpf.Program + events *ebpf.Map + writer *DaemonEventWriter + ownWriter bool } // StartCommandMonitor attaches execve/execveat tracepoints and starts logging. -func StartCommandMonitor(bundle *PolicyBundle) (*CommandMonitor, error) { +func StartCommandMonitor(bundle *PolicyBundle, sharedWriter ...*DaemonEventWriter) (*CommandMonitor, error) { cfg := bundle.Audit.Commands if cfg.HostJsonl == "" { return nil, fmt.Errorf("command audit hostJsonl is empty") @@ -55,9 +59,17 @@ func StartCommandMonitor(bundle *PolicyBundle) (*CommandMonitor, error) { return nil, fmt.Errorf("removing memlock limit: %w", err) } - writer, err := NewDaemonEventWriter(cfg.HostJsonl, cfg.ProjectMirrorJsonl, cfg.MirrorProjectEvents) - if err != nil { - return nil, fmt.Errorf("creating command event writer: %w", err) + var writer *DaemonEventWriter + ownWriter := false + if len(sharedWriter) > 0 && sharedWriter[0] != nil { + writer = sharedWriter[0] + } else { + var err error + writer, err = NewDaemonEventWriter(cfg.HostJsonl, cfg.ProjectMirrorJsonl, cfg.MirrorProjectEvents, bundle.Audit.Events.Rotation) + if err != nil { + return nil, fmt.Errorf("creating command event writer: %w", err) + } + ownWriter = true } events, err := ebpf.NewMap(&ebpf.MapSpec{ @@ -65,27 +77,28 @@ func StartCommandMonitor(bundle *PolicyBundle) (*CommandMonitor, error) { Name: "command_events", }) if err != nil { - writer.Close() + if ownWriter { + writer.Close() + } return nil, fmt.Errorf("creating command event map: %w", err) } rd, err := perf.NewReader(events, os.Getpagesize()) if err != nil { events.Close() - writer.Close() + if ownWriter { + writer.Close() + } return nil, fmt.Errorf("creating command event reader: %w", err) } monitor := &CommandMonitor{ - rd: rd, - events: events, - writer: writer, - } - if err := monitor.attachTracepoint(commandHookExecve, commandSyscallExecve); err != nil { - monitor.Close() - return nil, err + rd: rd, + events: events, + writer: writer, + ownWriter: ownWriter, } - if err := monitor.attachTracepoint(commandHookExecveat, commandSyscallExecveat); err != nil { + if err := monitor.attachTracepoint("sched", commandHookSchedExec, commandHookSchedExec, commandSyscallSchedExec); err != nil { monitor.Close() return nil, err } @@ -98,7 +111,7 @@ func StartCommandMonitor(bundle *PolicyBundle) (*CommandMonitor, error) { return monitor, nil } -func (m *CommandMonitor) attachTracepoint(hook string, syscall uint32) error { +func (m *CommandMonitor) attachTracepoint(category, event, hook string, syscall uint32) error { prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{ Name: "command_audit_" + hook, Type: ebpf.TracePoint, @@ -110,7 +123,7 @@ func (m *CommandMonitor) attachTracepoint(hook string, syscall uint32) error { } m.progs = append(m.progs, prog) - tp, err := link.Tracepoint("syscalls", "sys_enter_"+hook, prog, nil) + tp, err := link.Tracepoint(category, event, prog, nil) if err != nil { return fmt.Errorf("attaching command audit %s tracepoint: %w", hook, err) } @@ -165,10 +178,12 @@ func (m *CommandMonitor) run(ctx context.Context, bundle *PolicyBundle) { } if record.LostSamples > 0 { fmt.Fprintf(os.Stderr, "policy-ebpfd: lost %d command audit samples\n", record.LostSamples) + _ = m.writer.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "command-audit", Reason: "lost-samples", LostSamples: record.LostSamples}) } ev, err := commandEventFromSample(record.RawSample, bundle) if err != nil { fmt.Fprintf(os.Stderr, "policy-ebpfd: parsing command audit event: %v\n", err) + _ = m.writer.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "command-audit", Reason: "parse-error", Error: err.Error()}) continue } if ev == nil { @@ -176,6 +191,7 @@ func (m *CommandMonitor) run(ctx context.Context, bundle *PolicyBundle) { } if err := m.writer.WriteCommand(*ev); err != nil { fmt.Fprintf(os.Stderr, "policy-ebpfd: writing command audit event: %v\n", err) + _ = m.writer.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "command-audit", Reason: "write-error", Error: err.Error()}) } } } @@ -190,8 +206,11 @@ func commandEventFromSample(raw []byte, bundle *PolicyBundle) (*CommandEvent, er Syscall: binary.LittleEndian.Uint32(raw[8:12]), } hook := commandHookExecve - if ev.Syscall == commandSyscallExecveat { + switch ev.Syscall { + case commandSyscallExecveat: hook = commandHookExecveat + case commandSyscallSchedExec: + hook = commandHookSchedExec } enriched := enrichCommandEvent(int(ev.PID), int(ev.UID), hook, bundle) if !commandEventAllowed(enriched, bundle.Audit.Commands) { @@ -380,7 +399,7 @@ func (m *CommandMonitor) Close() error { firstErr = err } } - if m.writer != nil { + if m.writer != nil && m.ownWriter { if err := m.writer.Close(); err != nil && firstErr == nil { firstErr = err } diff --git a/cmd/policy-ebpfd/events.go b/cmd/policy-ebpfd/events.go index 0ff12f6..71d0a92 100644 --- a/cmd/policy-ebpfd/events.go +++ b/cmd/policy-ebpfd/events.go @@ -1,12 +1,9 @@ package main import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "sync" - "time" + + auditlog "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" ) // Event mirrors internal/runtime.NetworkEvent for the daemon. @@ -48,97 +45,67 @@ type CommandEvent struct { // DaemonEventWriter writes JSONL events from inside the init container. type DaemonEventWriter struct { - mu sync.Mutex - hostFile *os.File - mirrorFile *os.File + writer *auditlog.Writer } // NewDaemonEventWriter opens the event sinks configured in the bundle. -func NewDaemonEventWriter(hostPath, mirrorPath string, mirror bool) (*DaemonEventWriter, error) { - if err := os.MkdirAll(filepath.Dir(hostPath), 0755); err != nil { - return nil, fmt.Errorf("creating host event dir: %w", err) - } - hostFile, err := os.OpenFile(hostPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) +func NewDaemonEventWriter(hostPath, mirrorPath string, mirror bool, rotation auditlog.RotationConfig) (*DaemonEventWriter, error) { + writer, err := auditlog.NewWriter(hostPath, mirrorPath, mirror, rotation) if err != nil { - return nil, fmt.Errorf("opening host event file: %w", err) - } - - var mirrorFile *os.File - if mirror && mirrorPath != "" { - if err := os.MkdirAll(filepath.Dir(mirrorPath), 0755); err != nil { - hostFile.Close() - return nil, fmt.Errorf("creating mirror event dir: %w", err) - } - mirrorFile, err = os.OpenFile(mirrorPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - hostFile.Close() - return nil, fmt.Errorf("opening mirror event file: %w", err) - } + return nil, err } - - return &DaemonEventWriter{ - hostFile: hostFile, - mirrorFile: mirrorFile, - }, nil + return &DaemonEventWriter{writer: writer}, nil } // Write emits a single event to all configured sinks. func (w *DaemonEventWriter) Write(ev Event) error { - return w.writeJSON(ev) + return w.writer.Write(auditlog.Event{ + EventType: auditlog.EventNetworkConnect, + RunID: ev.RunID, + Project: ev.Project, + Backend: ev.Backend, + Hook: ev.Hook, + PID: ev.PID, + Process: ev.Process, + Protocol: ev.Protocol, + DstIP: ev.DstIP, + DstPort: ev.DstPort, + Decision: ev.Decision, + Reason: ev.Reason, + MatchedRule: ev.MatchedRule, + }) } // WriteCommand emits a single command audit event to all configured sinks. func (w *DaemonEventWriter) WriteCommand(ev CommandEvent) error { - return w.writeJSON(ev) + return w.writer.Write(auditlog.Event{ + EventType: auditlog.EventCommandExec, + RunID: ev.RunID, + Project: ev.Project, + Backend: ev.Backend, + Hook: ev.Hook, + PID: ev.PID, + PPID: ev.PPID, + UID: ev.UID, + GID: ev.GID, + CWD: ev.CWD, + Exe: ev.Exe, + Argv: ev.Argv, + Argc: ev.Argc, + Truncated: ev.Truncated, + Decision: ev.Decision, + Reason: ev.Reason, + }) } -func (w *DaemonEventWriter) writeJSON(ev any) error { - switch typed := ev.(type) { - case Event: - if typed.TS == "" { - typed.TS = time.Now().UTC().Format(time.RFC3339) - } - ev = typed - case CommandEvent: - if typed.TS == "" { - typed.TS = time.Now().UTC().Format(time.RFC3339) - } - ev = typed - } - - line, err := json.Marshal(ev) - if err != nil { - return fmt.Errorf("marshaling event: %w", err) +func (w *DaemonEventWriter) WriteAudit(ev auditlog.Event) error { + if ev.EventType == "" { + return fmt.Errorf("audit event type is empty") } - line = append(line, '\n') - - w.mu.Lock() - defer w.mu.Unlock() - - if _, err := w.hostFile.Write(line); err != nil { - return fmt.Errorf("writing host event: %w", err) - } - if w.mirrorFile != nil { - if _, err := w.mirrorFile.Write(line); err != nil { - return fmt.Errorf("writing mirror event: %w", err) - } - } - return nil + return w.writer.Write(ev) } // Close flushes and closes all sinks. func (w *DaemonEventWriter) Close() error { - w.mu.Lock() - defer w.mu.Unlock() - - var firstErr error - if err := w.hostFile.Close(); err != nil { - firstErr = err - } - if w.mirrorFile != nil { - if err := w.mirrorFile.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr + return w.writer.Close() } diff --git a/cmd/policy-ebpfd/events_test.go b/cmd/policy-ebpfd/events_test.go index 3c30e27..4d4cd2e 100644 --- a/cmd/policy-ebpfd/events_test.go +++ b/cmd/policy-ebpfd/events_test.go @@ -7,13 +7,19 @@ import ( "strings" "testing" "time" + + auditlog "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" ) +func testRotation() auditlog.RotationConfig { + return auditlog.RotationConfig{MaxBytes: auditlog.DefaultRotationMaxBytes, MaxFiles: auditlog.DefaultRotationMaxFiles} +} + func TestDaemonEventWriterCreatesHostFile(t *testing.T) { dir := t.TempDir() hostPath := filepath.Join(dir, "network-events.jsonl") - w, err := NewDaemonEventWriter(hostPath, "", false) + w, err := NewDaemonEventWriter(hostPath, "", false, testRotation()) if err != nil { t.Fatalf("NewDaemonEventWriter failed: %v", err) } @@ -29,7 +35,7 @@ func TestDaemonEventWriterCreatesMirrorWhenEnabled(t *testing.T) { hostPath := filepath.Join(dir, "host.jsonl") mirrorPath := filepath.Join(dir, "mirror.jsonl") - w, err := NewDaemonEventWriter(hostPath, mirrorPath, true) + w, err := NewDaemonEventWriter(hostPath, mirrorPath, true, testRotation()) if err != nil { t.Fatalf("NewDaemonEventWriter failed: %v", err) } @@ -45,7 +51,7 @@ func TestDaemonEventWriterOmitsMirrorWhenDisabled(t *testing.T) { hostPath := filepath.Join(dir, "host.jsonl") mirrorPath := filepath.Join(dir, "mirror.jsonl") - w, err := NewDaemonEventWriter(hostPath, mirrorPath, false) + w, err := NewDaemonEventWriter(hostPath, mirrorPath, false, testRotation()) if err != nil { t.Fatalf("NewDaemonEventWriter failed: %v", err) } @@ -61,7 +67,7 @@ func TestDaemonEventWriterEmitsValidJSONL(t *testing.T) { dir := t.TempDir() hostPath := filepath.Join(dir, "network-events.jsonl") - w, err := NewDaemonEventWriter(hostPath, "", false) + w, err := NewDaemonEventWriter(hostPath, "", false, testRotation()) if err != nil { t.Fatalf("NewDaemonEventWriter failed: %v", err) } @@ -97,6 +103,13 @@ func TestDaemonEventWriterEmitsValidJSONL(t *testing.T) { if err := json.Unmarshal([]byte(lines[0]), &parsed); err != nil { t.Fatalf("parsing event JSON: %v", err) } + var raw map[string]any + if err := json.Unmarshal([]byte(lines[0]), &raw); err != nil { + t.Fatalf("parsing raw event JSON: %v", err) + } + if raw["schemaVersion"] == nil || raw["eventType"] != auditlog.EventNetworkConnect { + t.Fatalf("expected audit envelope fields, got %v", raw) + } if parsed.RunID != "run-1" { t.Errorf("unexpected runId: %s", parsed.RunID) @@ -119,7 +132,7 @@ func TestDaemonEventWriterNoSecrets(t *testing.T) { dir := t.TempDir() hostPath := filepath.Join(dir, "network-events.jsonl") - w, err := NewDaemonEventWriter(hostPath, "", false) + w, err := NewDaemonEventWriter(hostPath, "", false, testRotation()) if err != nil { t.Fatalf("NewDaemonEventWriter failed: %v", err) } diff --git a/cmd/policy-ebpfd/main.go b/cmd/policy-ebpfd/main.go index aaa82e2..8e6587a 100644 --- a/cmd/policy-ebpfd/main.go +++ b/cmd/policy-ebpfd/main.go @@ -10,6 +10,8 @@ import ( "fmt" "os" "path/filepath" + + auditlog "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" ) // PolicyBundle mirrors the runtime bundle shape. @@ -27,6 +29,7 @@ type PolicyBundle struct { FailClosed bool `json:"failClosed"` } `json:"network"` Audit struct { + Events AuditEventsConfig `json:"events"` Commands CommandAuditConfig `json:"commands"` } `json:"audit"` Rules struct { @@ -51,6 +54,14 @@ type PolicyBundle struct { Allowlist []string `json:"allowlist"` } +// AuditEventsConfig mirrors the unified audit event bundle shape. +type AuditEventsConfig struct { + HostJsonl string `json:"hostJsonl"` + ProjectMirrorJsonl string `json:"projectMirrorJsonl"` + MirrorProjectEvents bool `json:"mirrorProjectEvents"` + Rotation auditlog.RotationConfig `json:"rotation"` +} + // CommandAuditConfig mirrors the command audit bundle shape. type CommandAuditConfig struct { Enabled bool `json:"enabled"` @@ -93,11 +104,24 @@ func run() error { if err := validateBundle(bundle); err != nil { return fmt.Errorf("validating policy bundle: %w", err) } + normalizeAuditEventConfig(bundle) fmt.Printf("policy-ebpfd: runId=%s project=%s mode=%s backend=%s defaultAction=%s\n", bundle.RunID, bundle.Project.Name, bundle.Network.Mode, bundle.Network.Backend, bundle.Network.DefaultAction) + eventWriter, err := NewDaemonEventWriter( + bundle.Audit.Events.HostJsonl, + bundle.Audit.Events.ProjectMirrorJsonl, + bundle.Audit.Events.MirrorProjectEvents, + bundle.Audit.Events.Rotation, + ) + if err != nil { + return fmt.Errorf("creating event writer: %w", err) + } + defer eventWriter.Close() + _ = eventWriter.WriteAudit(healthEvent(bundle, "daemon", true, "daemon-start")) + // Probe available eBPF capabilities. caps := probeCapabilities() fmt.Printf("policy-ebpfd: cgroup2 available=%v\n", caps.Cgroup2Available) @@ -110,6 +134,7 @@ func run() error { enforcer, attachErr = tryAttachHooks(caps, bundle) if attachErr != nil { fmt.Fprintf(os.Stderr, "policy-ebpfd: network hook attach failed: %v\n", attachErr) + _ = eventWriter.WriteAudit(healthEvent(bundle, "network-hook", false, attachErr.Error())) if bundle.Network.FailClosed { return fmt.Errorf("fail-closed: cannot establish eBPF enforcement") } @@ -122,9 +147,10 @@ func run() error { var commandMonitor *CommandMonitor if bundle.Audit.Commands.Enabled { - monitor, err := StartCommandMonitor(bundle) + monitor, err := StartCommandMonitor(bundle, eventWriter) if err != nil { fmt.Fprintf(os.Stderr, "policy-ebpfd: command audit attach failed: %v\n", err) + _ = eventWriter.WriteAudit(healthEvent(bundle, "command-audit", false, err.Error())) if bundle.Audit.Commands.FailClosed { return fmt.Errorf("fail-closed: cannot establish command audit") } @@ -132,33 +158,21 @@ func run() error { } else { commandMonitor = monitor defer commandMonitor.Close() + _ = commandMonitor.writer.WriteAudit(healthEvent(bundle, "command-audit", true, "attached")) fmt.Println("policy-ebpfd: eBPF command audit attached successfully") } } - // Start network event writer. - eventWriter, err := NewDaemonEventWriter( - bundle.Events.HostJsonl, - bundle.Events.ProjectMirrorJsonl, - bundle.Events.MirrorProjectEvents, - ) - if err != nil { - return fmt.Errorf("creating event writer: %w", err) + if enforcer != nil { + _ = eventWriter.WriteAudit(healthEvent(bundle, "network-hook", true, "attached")) + networkMonitor, err := StartNetworkEventMonitor(bundle, enforcer, eventWriter) + if err != nil { + _ = eventWriter.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "network-connect", Reason: "attach-failed", Error: err.Error()}) + } else { + defer networkMonitor.Close() + _ = eventWriter.WriteAudit(healthEvent(bundle, "network-connect", true, "reader-started")) + } } - defer eventWriter.Close() - - // Log daemon startup event. - _ = eventWriter.Write(Event{ - RunID: bundle.RunID, - Project: bundle.Project.Name, - Backend: "ebpf", - Hook: "daemon-start", - Protocol: "", - DstIP: "", - DstPort: 0, - Decision: "allow", - Reason: "daemon-start", - }) if bundle.Network.Backend == "ebpf" { // Start resolver/control loop. @@ -179,15 +193,70 @@ func run() error { ctx := context.Background() if err := resolver.Run(ctx); err != nil { fmt.Fprintf(os.Stderr, "resolver: %v\n", err) + _ = eventWriter.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "resolver", Error: err.Error()}) } }() + _ = eventWriter.WriteAudit(healthEvent(bundle, "resolver", true, "started")) fmt.Println("policy-ebpfd: resolver started") } + _ = eventWriter.WriteAudit(healthEvent(bundle, "daemon", true, "daemon-ready")) fmt.Println("policy-ebpfd: daemon ready") return nil } +func normalizeAuditEventConfig(bundle *PolicyBundle) { + if bundle.Audit.Events.HostJsonl == "" { + bundle.Audit.Events.HostJsonl = bundle.Events.HostJsonl + } + if bundle.Audit.Events.ProjectMirrorJsonl == "" { + bundle.Audit.Events.ProjectMirrorJsonl = bundle.Events.ProjectMirrorJsonl + } + if !bundle.Audit.Events.MirrorProjectEvents { + bundle.Audit.Events.MirrorProjectEvents = bundle.Events.MirrorProjectEvents || bundle.Audit.Commands.MirrorProjectEvents + } + if bundle.Audit.Events.Rotation.MaxBytes == 0 { + bundle.Audit.Events.Rotation.MaxBytes = auditlog.DefaultRotationMaxBytes + } + if bundle.Audit.Events.Rotation.MaxFiles == 0 { + bundle.Audit.Events.Rotation.MaxFiles = auditlog.DefaultRotationMaxFiles + } + if bundle.Audit.Commands.HostJsonl == "" { + bundle.Audit.Commands.HostJsonl = bundle.Audit.Events.HostJsonl + } + if bundle.Audit.Commands.ProjectMirrorJsonl == "" { + bundle.Audit.Commands.ProjectMirrorJsonl = bundle.Audit.Events.ProjectMirrorJsonl + } +} + +func healthEvent(bundle *PolicyBundle, component string, active bool, message string) auditlog.Event { + return auditlog.Event{ + EventType: auditlog.EventDaemonHealth, + RunID: bundle.RunID, + Project: bundle.Project.Name, + Backend: "ebpf", + Component: component, + Status: message, + Active: &active, + Attached: &active, + Message: message, + } +} + +func writeStartupHealth(bundle *PolicyBundle, component string, active bool, message string) error { + writer, err := NewDaemonEventWriter( + bundle.Audit.Events.HostJsonl, + bundle.Audit.Events.ProjectMirrorJsonl, + bundle.Audit.Events.MirrorProjectEvents, + bundle.Audit.Events.Rotation, + ) + if err != nil { + return err + } + defer writer.Close() + return writer.WriteAudit(healthEvent(bundle, component, active, message)) +} + func loadPolicyBundle(path string) (*PolicyBundle, error) { data, err := os.ReadFile(path) if err != nil { diff --git a/cmd/policy-ebpfd/network_events.go b/cmd/policy-ebpfd/network_events.go new file mode 100644 index 0000000..b20c017 --- /dev/null +++ b/cmd/policy-ebpfd/network_events.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "net" + "os" + "sync" + + auditlog "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" + "github.com/cilium/ebpf/perf" +) + +const ( + networkReasonAllowlist = uint32(1) + networkReasonBlocklist = uint32(2) + networkReasonDefaultAllow = uint32(3) + networkReasonDefaultDeny = uint32(4) +) + +type bpfNetworkEvent struct { + PID uint32 + DstIP uint32 + DstPort uint32 + Decision uint32 + Reason uint32 + RuleID uint32 + _ uint32 +} + +type NetworkEventMonitor struct { + cancel context.CancelFunc + wg sync.WaitGroup + rd *perf.Reader + handle *enforcementHandle + writer *DaemonEventWriter +} + +func StartNetworkEventMonitor(bundle *PolicyBundle, handle *enforcementHandle, writer *DaemonEventWriter) (*NetworkEventMonitor, error) { + if handle == nil || handle.events == nil { + return nil, fmt.Errorf("network event map is not available") + } + rd, err := perf.NewReader(handle.events, os.Getpagesize()) + if err != nil { + return nil, fmt.Errorf("creating network event reader: %w", err) + } + ctx, cancel := context.WithCancel(context.Background()) + monitor := &NetworkEventMonitor{cancel: cancel, rd: rd, handle: handle, writer: writer} + monitor.wg.Add(1) + go monitor.run(ctx, bundle) + return monitor, nil +} + +func (m *NetworkEventMonitor) run(ctx context.Context, bundle *PolicyBundle) { + defer m.wg.Done() + for { + record, err := m.rd.Read() + if err != nil { + if errors.Is(err, perf.ErrClosed) { + return + } + select { + case <-ctx.Done(): + return + default: + _ = m.writer.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "network-connect", Reason: "read-error", Error: err.Error()}) + continue + } + } + if record.LostSamples > 0 { + _ = m.writer.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "network-connect", Reason: "lost-samples", LostSamples: record.LostSamples}) + } + event, err := networkEventFromSample(record.RawSample, bundle, m.handle) + if err != nil { + _ = m.writer.WriteAudit(auditlog.Event{EventType: auditlog.EventAuditError, RunID: bundle.RunID, Project: bundle.Project.Name, Backend: "ebpf", Component: "network-connect", Reason: "parse-error", Error: err.Error()}) + continue + } + if err := m.writer.WriteAudit(event); err != nil { + fmt.Fprintf(os.Stderr, "policy-ebpfd: writing network event: %v\n", err) + } + } +} + +func networkEventFromSample(raw []byte, bundle *PolicyBundle, handle *enforcementHandle) (auditlog.Event, error) { + if len(raw) < 28 { + return auditlog.Event{}, fmt.Errorf("short network event sample: %d bytes", len(raw)) + } + ev := bpfNetworkEvent{ + PID: binary.LittleEndian.Uint32(raw[0:4]), + DstIP: binary.LittleEndian.Uint32(raw[4:8]), + DstPort: binary.LittleEndian.Uint32(raw[8:12]), + Decision: binary.LittleEndian.Uint32(raw[12:16]), + Reason: binary.LittleEndian.Uint32(raw[16:20]), + RuleID: binary.LittleEndian.Uint32(raw[20:24]), + } + decision := "block" + if ev.Decision != 0 { + decision = "allow" + } + dstIP := make(net.IP, 4) + binary.BigEndian.PutUint32(dstIP, ev.DstIP) + port := int(ev.DstPort) + if port > 65535 { + port = int(binary.BigEndian.Uint16(raw[8:10])) + } + return auditlog.Event{ + EventType: auditlog.EventNetworkConnect, + RunID: bundle.RunID, + Project: bundle.Project.Name, + Backend: "ebpf", + Hook: "cgroup-connect4", + PID: int(ev.PID), + Protocol: "tcp", + DstIP: dstIP.String(), + DstPort: port, + Decision: decision, + Reason: networkReasonText(ev.Reason), + MatchedRule: handle.ruleName(ev.RuleID), + }, nil +} + +func networkReasonText(reason uint32) string { + switch reason { + case networkReasonAllowlist: + return "allowlist" + case networkReasonBlocklist: + return "blocklist" + case networkReasonDefaultAllow: + return "default-allow" + case networkReasonDefaultDeny: + return "default-deny" + default: + return "unknown" + } +} + +func (m *NetworkEventMonitor) Close() error { + if m.cancel != nil { + m.cancel() + } + if m.rd != nil { + _ = m.rd.Close() + } + m.wg.Wait() + return nil +} diff --git a/cmd/policy-proxy/main.go b/cmd/policy-proxy/main.go index 4d035c6..8490efc 100644 --- a/cmd/policy-proxy/main.go +++ b/cmd/policy-proxy/main.go @@ -10,6 +10,8 @@ import ( "os" "strings" "time" + + auditlog "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" ) // Policy describes the network policy. @@ -18,6 +20,21 @@ type Policy struct { DefaultAction string `json:"defaultAction"` Blocklist []string `json:"blocklist"` Allowlist []string `json:"allowlist"` + RunID string `json:"runId"` + Project struct { + Name string `json:"name"` + } `json:"project"` + Network struct { + Backend string `json:"backend"` + } `json:"network"` + Audit struct { + Events struct { + HostJsonl string `json:"hostJsonl"` + ProjectMirrorJsonl string `json:"projectMirrorJsonl"` + MirrorProjectEvents bool `json:"mirrorProjectEvents"` + Rotation auditlog.RotationConfig `json:"rotation"` + } `json:"events"` + } `json:"audit"` } func main() { @@ -42,12 +59,26 @@ func main() { proxyPort = "18080" } - logFile := os.Getenv("POLICY_LOG_FILE") - if logFile == "" { - logFile = "/sandbox/logs/network.log" + hostLog := policy.Audit.Events.HostJsonl + if hostLog == "" { + hostLog = os.Getenv("POLICY_LOG_FILE") } - - logger, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if hostLog == "" { + hostLog = "/sandbox/logs/" + auditlog.DefaultFileName + } + rotation := policy.Audit.Events.Rotation + if rotation.MaxBytes == 0 { + rotation.MaxBytes = auditlog.DefaultRotationMaxBytes + } + if rotation.MaxFiles == 0 { + rotation.MaxFiles = auditlog.DefaultRotationMaxFiles + } + logger, err := auditlog.NewWriter( + hostLog, + policy.Audit.Events.ProjectMirrorJsonl, + policy.Audit.Events.MirrorProjectEvents, + rotation, + ) if err != nil { log.Fatalf("opening log file: %v", err) } @@ -70,7 +101,7 @@ func main() { // Proxy implements an HTTP CONNECT proxy with policy enforcement. type Proxy struct { Policy Policy - Logger *os.File + Logger *auditlog.Writer } func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -88,24 +119,23 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { return } - hostname, _, _ := net.SplitHostPort(host) - if hostname == "" { - hostname = host - } + hostname, port := splitHostPort(host) decision, rule := p.decide(hostname) if decision == "block" { - p.logBlock(hostname, rule, "CONNECT") + p.logNetwork(hostname, port, "CONNECT", decision, "policy-block", rule, "") http.Error(w, "blocked by policy", http.StatusForbidden) return } dest, err := net.DialTimeout("tcp", host, 10*time.Second) if err != nil { + p.logNetwork(hostname, port, "CONNECT", "error", "upstream-error", rule, err.Error()) http.Error(w, err.Error(), http.StatusServiceUnavailable) return } defer dest.Close() + p.logNetwork(hostname, port, "CONNECT", decision, "policy-allow", rule, "") w.WriteHeader(http.StatusOK) @@ -133,20 +163,28 @@ func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { if hostname == "" { hostname = r.Host } + hostname, port := splitHostPort(hostname) + if port == 0 { + port = defaultPort(r.URL.Scheme) + } decision, rule := p.decide(hostname) if decision == "block" { - p.logBlock(hostname, rule, "HTTP") + p.logNetwork(hostname, port, "HTTP", decision, "policy-block", rule, "") http.Error(w, "blocked by policy", http.StatusForbidden) return } - resp, err := http.DefaultTransport.RoundTrip(r) + outReq := r.Clone(r.Context()) + outReq.RequestURI = "" + resp, err := http.DefaultTransport.RoundTrip(outReq) if err != nil { + p.logNetwork(hostname, port, "HTTP", "error", "upstream-error", rule, err.Error()) http.Error(w, err.Error(), http.StatusServiceUnavailable) return } defer resp.Body.Close() + p.logNetwork(hostname, port, "HTTP", decision, "policy-allow", rule, "") for k, v := range resp.Header { w.Header()[k] = v @@ -178,10 +216,28 @@ func (p *Proxy) decide(hostname string) (string, string) { return "allow", "" } -func (p *Proxy) logBlock(hostname, rule, method string) { - ts := time.Now().UTC().Format(time.RFC3339) - line := fmt.Sprintf("%s blocked=%s rule=%q method=%s mode=%s\n", ts, hostname, rule, method, p.Policy.Mode) - p.Logger.WriteString(line) +func (p *Proxy) logNetwork(hostname string, port int, method, decision, reason, rule, errText string) { + if p.Logger == nil { + return + } + backend := p.Policy.Network.Backend + if backend == "" { + backend = "proxy" + } + _ = p.Logger.Write(auditlog.Event{ + EventType: auditlog.EventNetworkConnect, + RunID: p.Policy.RunID, + Project: p.Policy.Project.Name, + Backend: backend, + Method: method, + Protocol: "tcp", + Host: hostname, + DstPort: port, + Decision: decision, + Reason: reason, + MatchedRule: rule, + Error: errText, + }) } func matchDomain(domain, rule string) bool { @@ -191,3 +247,23 @@ func matchDomain(domain, rule string) bool { } return domain == rule } + +func splitHostPort(hostport string) (string, int) { + hostname, portText, err := net.SplitHostPort(hostport) + if err != nil { + return strings.Trim(hostport, "[]"), 0 + } + port, _ := net.LookupPort("tcp", portText) + return hostname, port +} + +func defaultPort(scheme string) int { + switch scheme { + case "https": + return 443 + case "http": + return 80 + default: + return 0 + } +} diff --git a/cmd/policy-proxy/main_test.go b/cmd/policy-proxy/main_test.go index abaab9f..6bace03 100644 --- a/cmd/policy-proxy/main_test.go +++ b/cmd/policy-proxy/main_test.go @@ -1,6 +1,16 @@ package main -import "testing" +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + auditlog "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" +) func TestProxyDecisionHonorsDefaultAction(t *testing.T) { p := &Proxy{Policy: Policy{Mode: "practical", DefaultAction: "deny"}} @@ -34,3 +44,99 @@ func TestProxyDecisionBlocksRule(t *testing.T) { t.Fatalf("expected blocklist match, got decision=%s rule=%s", decision, rule) } } + +func TestProxyLogsBlockedHTTPAsJSON(t *testing.T) { + proxy, path := testProxyWithLog(t, Policy{ + Mode: "practical", + DefaultAction: "allow", + Blocklist: []string{"blocked.example.com"}, + }) + + req := httptest.NewRequest(http.MethodGet, "http://blocked.example.com/path?token=secret", nil) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected forbidden, got %d", rec.Code) + } + event := readOneAuditEvent(t, path) + if event.EventType != auditlog.EventNetworkConnect || event.Decision != "block" { + t.Fatalf("unexpected event: %+v", event) + } + if event.Host != "blocked.example.com" || event.Method != "HTTP" { + t.Fatalf("unexpected host/method: %+v", event) + } + data, _ := os.ReadFile(path) + for _, forbidden := range []string{"/path", "token", "secret"} { + if strings.Contains(string(data), forbidden) { + t.Fatalf("audit event leaked %q: %s", forbidden, data) + } + } +} + +func TestProxyLogsAllowedHTTPAsJSON(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + proxy, path := testProxyWithLog(t, Policy{Mode: "practical", DefaultAction: "allow"}) + req := httptest.NewRequest(http.MethodGet, upstream.URL, nil) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected upstream status, got %d", rec.Code) + } + event := readOneAuditEvent(t, path) + if event.Decision != "allow" || event.Reason != "policy-allow" { + t.Fatalf("unexpected event: %+v", event) + } +} + +func TestProxyLogsUpstreamErrorAsJSON(t *testing.T) { + proxy, path := testProxyWithLog(t, Policy{Mode: "practical", DefaultAction: "allow"}) + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:1/", nil) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected service unavailable, got %d", rec.Code) + } + event := readOneAuditEvent(t, path) + if event.Decision != "error" || event.Reason != "upstream-error" || event.Error == "" { + t.Fatalf("unexpected event: %+v", event) + } +} + +func testProxyWithLog(t *testing.T, policy Policy) (*Proxy, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, auditlog.DefaultFileName) + writer, err := auditlog.NewWriter(path, "", false, auditlog.RotationConfig{MaxBytes: auditlog.DefaultRotationMaxBytes, MaxFiles: auditlog.DefaultRotationMaxFiles}) + if err != nil { + t.Fatalf("NewWriter failed: %v", err) + } + t.Cleanup(func() { _ = writer.Close() }) + policy.RunID = "run-1" + policy.Project.Name = "proj" + policy.Network.Backend = "proxy" + return &Proxy{Policy: policy, Logger: writer}, path +} + +func readOneAuditEvent(t *testing.T, path string) auditlog.Event { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading audit log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("expected one event, got %d: %s", len(lines), data) + } + var event auditlog.Event + if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { + t.Fatalf("unmarshal audit event: %v", err) + } + return event +} diff --git a/docs/implementation-spec.md b/docs/implementation-spec.md index 4ec32a2..9369125 100644 --- a/docs/implementation-spec.md +++ b/docs/implementation-spec.md @@ -1050,7 +1050,7 @@ Suggested implementation: - `NO_PROXY=localhost,127.0.0.1,::1` - Deny DNS answers for blocklisted domains. - Deny HTTP CONNECT or HTTP absolute-form proxy requests for blocklisted hosts. -- Log blocked attempts to `/sandbox/logs/network.log`. +- Log network and command audit attempts to `/sandbox/logs/audit-events.jsonl`. Practical mode limitations to document: diff --git a/docs/network-policy.md b/docs/network-policy.md index 9c8f9a3..08ee36e 100644 --- a/docs/network-policy.md +++ b/docs/network-policy.md @@ -152,19 +152,14 @@ network: ## Event Logs -Strict mode writes JSONL event logs. Practical proxy mode writes proxy block logs. +Strict mode and practical proxy mode write structured JSONL events to the unified audit log. -- **Host state**: `~/.local/state/opencode-sandbox/runs//network-events.jsonl` -- **Project mirror** (optional): `/.opencode-sandbox/network-events.jsonl` +- **Host state**: `~/.local/state/opencode-sandbox/runs//audit-events.jsonl` +- **Project mirror** (optional): `/.opencode-sandbox/audit-events.jsonl` -Strict event fields include timestamp, run ID, project, backend, hook, protocol, destination IP/port, decision, reason, and matched rule when available. Events never include URLs, query strings, headers, request bodies, or secrets. +Network event fields include schema version, event type, timestamp, run ID, project, backend, hook or method, protocol, host or destination IP/port, decision, reason, and matched rule when available. Events never include URLs, query strings, headers, request bodies, or secrets. -Command audit events are opt-in while the custom init image path is experimental. When enabled, they are written beside network events. - -- **Host state**: `~/.local/state/opencode-sandbox/runs//command-events.jsonl` -- **Project mirror** (optional): `/.opencode-sandbox/command-events.jsonl` - -Command audit records `execve` and `execveat` process launches inside the container VM, including tools such as `curl`, `git`, `npm`, shell-spawned commands, and helper binaries. It logs full argv by default, which can include secrets passed on the command line. Shell builtins that do not spawn a process are not separate events. +Command audit events are opt-in while the custom init image path is experimental. When enabled, they are written to the same audit log as `command.exec` events. Command audit records process launches inside the container VM, including tools such as `curl`, `git`, `npm`, shell-spawned commands, and helper binaries. It logs full argv by default, which can include secrets passed on the command line. Shell builtins that do not spawn a process are not separate events. ## Switching Backends diff --git a/docs/quickstart.md b/docs/quickstart.md index 1db8e08..e978773 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -208,16 +208,10 @@ The selected project is mounted into the container at: /workspace ``` -Wrapper state, OpenCode config/data/state, and event logs stay on the host. Network events are written under: +Wrapper state, OpenCode config/data/state, and event logs stay on the host. Network, command, daemon health, and audit error events are written under: ```text -~/.local/state/opencode-sandbox/runs//network-events.jsonl -``` - -Command audit events are opt-in while the custom init image path is experimental. When enabled, they are written under: - -```text -~/.local/state/opencode-sandbox/runs//command-events.jsonl +~/.local/state/opencode-sandbox/runs//audit-events.jsonl ``` Command audit uses eBPF exec tracing inside the container VM. It records process execs such as `curl`, `git`, `npm`, and helper binaries with full argv by default. Shell builtins that do not spawn a process are not separate events, and full argv can include secrets. diff --git a/docs/security-model.md b/docs/security-model.md index 5d9ba11..2c5de08 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -129,7 +129,7 @@ audit: Host log path: ```text -~/.local/state/opencode-sandbox/runs//command-events.jsonl +~/.local/state/opencode-sandbox/runs//audit-events.jsonl ``` ## Best Practices diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index bdb0362..043781f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -98,7 +98,7 @@ network: 3. Review event logs to see the exact decision: ```bash -cat ~/.local/state/opencode-sandbox/runs//network-events.jsonl +cat ~/.local/state/opencode-sandbox/runs//audit-events.jsonl ``` ### Event logs are empty diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..6526c70 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,261 @@ +package audit + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + SchemaVersion = 1 + + DefaultFileName = "audit-events.jsonl" + DefaultRotationMaxBytes = int64(10 * 1024 * 1024) + DefaultRotationMaxFiles = 5 + + EventCommandExec = "command.exec" + EventNetworkConnect = "network.connect" + EventDaemonHealth = "daemon.health" + EventLogRotate = "log.rotate" + EventAuditError = "audit.error" +) + +// RotationConfig controls size-based JSONL rotation. +type RotationConfig struct { + MaxBytes int64 `json:"maxBytes" yaml:"maxBytes,omitempty"` + MaxFiles int `json:"maxFiles" yaml:"maxFiles,omitempty"` +} + +// Event is the unified audit record written to audit-events.jsonl. +type Event struct { + SchemaVersion int `json:"schemaVersion"` + EventType string `json:"eventType"` + TS string `json:"ts"` + RunID string `json:"runId,omitempty"` + Project string `json:"project,omitempty"` + Backend string `json:"backend,omitempty"` + + Hook string `json:"hook,omitempty"` + Component string `json:"component,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + Active *bool `json:"active,omitempty"` + Attached *bool `json:"attached,omitempty"` + + PID int `json:"pid,omitempty"` + PPID int `json:"ppid,omitempty"` + UID int `json:"uid,omitempty"` + GID int `json:"gid,omitempty"` + Process string `json:"process,omitempty"` + CWD string `json:"cwd,omitempty"` + Exe string `json:"exe,omitempty"` + Argv []string `json:"argv,omitempty"` + Argc int `json:"argc,omitempty"` + Truncated bool `json:"truncated,omitempty"` + + Protocol string `json:"protocol,omitempty"` + Host string `json:"host,omitempty"` + DstIP string `json:"dstIp,omitempty"` + DstPort int `json:"dstPort,omitempty"` + Method string `json:"method,omitempty"` + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` + MatchedRule string `json:"matchedRule,omitempty"` + + LostSamples uint64 `json:"lostSamples,omitempty"` + PreviousPath string `json:"previousPath,omitempty"` + NextPath string `json:"nextPath,omitempty"` +} + +// Writer appends structured audit events to a host log and optional mirror. +type Writer struct { + mu sync.Mutex + host *sink + mirror *sink + rotator RotationConfig +} + +type sink struct { + path string + file *os.File + size int64 +} + +// NewWriter opens the configured audit sinks. +func NewWriter(hostPath, mirrorPath string, mirror bool, rotation RotationConfig) (*Writer, error) { + if hostPath == "" { + return nil, fmt.Errorf("audit host path is empty") + } + if rotation.MaxBytes < 0 { + return nil, fmt.Errorf("audit rotation maxBytes must be non-negative") + } + if rotation.MaxFiles < 0 { + return nil, fmt.Errorf("audit rotation maxFiles must be non-negative") + } + host, err := openSink(hostPath) + if err != nil { + return nil, err + } + var mirrorSink *sink + if mirror && mirrorPath != "" { + mirrorSink, err = openSink(mirrorPath) + if err != nil { + _ = host.file.Close() + return nil, err + } + } + return &Writer{host: host, mirror: mirrorSink, rotator: rotation}, nil +} + +func openSink(path string) (*sink, error) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, fmt.Errorf("creating audit log dir: %w", err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("opening audit log file: %w", err) + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("stat audit log file: %w", err) + } + return &sink{path: path, file: file, size: info.Size()}, nil +} + +// Write appends one audit event to all configured sinks. +func (w *Writer) Write(ev Event) error { + if ev.SchemaVersion == 0 { + ev.SchemaVersion = SchemaVersion + } + if ev.TS == "" { + ev.TS = time.Now().UTC().Format(time.RFC3339Nano) + } + line, err := marshalLine(ev) + if err != nil { + return err + } + + w.mu.Lock() + defer w.mu.Unlock() + + if err := w.rotateIfNeededLocked(int64(len(line))); err != nil { + return err + } + return w.writeLineLocked(line) +} + +func marshalLine(ev Event) ([]byte, error) { + line, err := json.Marshal(ev) + if err != nil { + return nil, fmt.Errorf("marshaling audit event: %w", err) + } + return append(line, '\n'), nil +} + +func (w *Writer) rotateIfNeededLocked(incoming int64) error { + if w.rotator.MaxBytes <= 0 || w.host.size == 0 || w.host.size+incoming <= w.rotator.MaxBytes { + return nil + } + previous := w.host.path + if err := w.rotateSinkLocked(w.host); err != nil { + return err + } + if w.mirror != nil { + if err := w.rotateSinkLocked(w.mirror); err != nil { + return err + } + } + rotationEvent := Event{ + SchemaVersion: SchemaVersion, + EventType: EventLogRotate, + TS: time.Now().UTC().Format(time.RFC3339Nano), + PreviousPath: previous, + NextPath: w.host.path, + } + line, err := marshalLine(rotationEvent) + if err != nil { + return err + } + return w.writeLineLocked(line) +} + +func (w *Writer) rotateSinkLocked(s *sink) error { + if err := s.file.Close(); err != nil { + return fmt.Errorf("closing audit log for rotation: %w", err) + } + if w.rotator.MaxFiles <= 0 { + if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing audit log during rotation: %w", err) + } + } else { + oldest := rotatedPath(s.path, w.rotator.MaxFiles) + if err := os.Remove(oldest); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing old rotated audit log: %w", err) + } + for i := w.rotator.MaxFiles - 1; i >= 1; i-- { + from := rotatedPath(s.path, i) + to := rotatedPath(s.path, i+1) + if err := os.Rename(from, to); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("renaming rotated audit log: %w", err) + } + } + if err := os.Rename(s.path, rotatedPath(s.path, 1)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("rotating audit log: %w", err) + } + } + reopened, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("reopening audit log after rotation: %w", err) + } + s.file = reopened + s.size = 0 + return nil +} + +func rotatedPath(path string, index int) string { + return fmt.Sprintf("%s.%d", path, index) +} + +func (w *Writer) writeLineLocked(line []byte) error { + if err := writeSinkLine(w.host, line); err != nil { + return err + } + if w.mirror != nil { + if err := writeSinkLine(w.mirror, line); err != nil { + return err + } + } + return nil +} + +func writeSinkLine(s *sink, line []byte) error { + if _, err := s.file.Write(line); err != nil { + return fmt.Errorf("writing audit event: %w", err) + } + s.size += int64(len(line)) + return nil +} + +// Close closes all configured audit sinks. +func (w *Writer) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + + var firstErr error + if w.host != nil && w.host.file != nil { + if err := w.host.file.Close(); err != nil { + firstErr = err + } + } + if w.mirror != nil && w.mirror.file != nil { + if err := w.mirror.file.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go new file mode 100644 index 0000000..e94d04f --- /dev/null +++ b/internal/audit/audit_test.go @@ -0,0 +1,102 @@ +package audit + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriterEmitsJSONLWithCommonFields(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFileName) + w, err := NewWriter(path, "", false, RotationConfig{MaxBytes: DefaultRotationMaxBytes, MaxFiles: DefaultRotationMaxFiles}) + if err != nil { + t.Fatalf("NewWriter failed: %v", err) + } + defer w.Close() + + if err := w.Write(Event{EventType: EventDaemonHealth, RunID: "run-1", Project: "proj", Backend: "ebpf", Status: "ready"}); err != nil { + t.Fatalf("Write failed: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading audit log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("expected one JSONL line, got %d", len(lines)) + } + var parsed Event + if err := json.Unmarshal([]byte(lines[0]), &parsed); err != nil { + t.Fatalf("unmarshal audit event: %v", err) + } + if parsed.SchemaVersion != SchemaVersion || parsed.EventType != EventDaemonHealth { + t.Fatalf("unexpected common fields: %+v", parsed) + } + if parsed.TS == "" { + t.Fatal("expected timestamp") + } +} + +func TestWriterMirrorsEvents(t *testing.T) { + dir := t.TempDir() + host := filepath.Join(dir, "host", DefaultFileName) + mirror := filepath.Join(dir, "mirror", DefaultFileName) + w, err := NewWriter(host, mirror, true, RotationConfig{MaxBytes: DefaultRotationMaxBytes, MaxFiles: DefaultRotationMaxFiles}) + if err != nil { + t.Fatalf("NewWriter failed: %v", err) + } + if err := w.Write(Event{EventType: EventCommandExec, RunID: "run-1", Exe: "/bin/sh"}); err != nil { + t.Fatalf("Write failed: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + hostData, _ := os.ReadFile(host) + mirrorData, _ := os.ReadFile(mirror) + if string(hostData) != string(mirrorData) { + t.Fatalf("expected mirror to match host\nhost=%s\nmirror=%s", hostData, mirrorData) + } +} + +func TestWriterRotatesBySizeAndKeepsRetention(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, DefaultFileName) + w, err := NewWriter(path, "", false, RotationConfig{MaxBytes: 180, MaxFiles: 2}) + if err != nil { + t.Fatalf("NewWriter failed: %v", err) + } + for i := 0; i < 6; i++ { + if err := w.Write(Event{EventType: EventAuditError, RunID: "run-1", Error: strings.Repeat("x", 40)}); err != nil { + t.Fatalf("Write %d failed: %v", i, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected active log: %v", err) + } + if _, err := os.Stat(path + ".1"); err != nil { + t.Fatalf("expected first rotated log: %v", err) + } + if _, err := os.Stat(path + ".2"); err != nil { + t.Fatalf("expected second rotated log: %v", err) + } + if _, err := os.Stat(path + ".3"); !os.IsNotExist(err) { + t.Fatalf("expected retention to prune third rotated log") + } + + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), EventLogRotate) { + t.Fatalf("expected active log to contain rotate event, got %s", data) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index eccda45..8e4d6b1 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -153,10 +153,7 @@ func buildRunContainerPlan(plan RunPlan, opts effectiveRunOptions) (containercmd return containercmd.Plan{}, cleanup, fmt.Errorf("generating policy bundle: %w", err) } - eventLogBase := effective.Network.EBPF.EventLog - if eventLogBase == "" { - eventLogBase = effective.Audit.Commands.EventLog - } + eventLogBase := config.AuditEventLogBase(effective) eventLogDir, err = runtime.EventLogDirForBase(runID, eventLogBase) if err != nil { return containercmd.Plan{}, cleanup, fmt.Errorf("resolving event log dir: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index 7b14b0c..a67531b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,7 +77,15 @@ type EBPF struct { // Audit holds observability and audit settings. type Audit struct { - Commands *CommandAudit `yaml:"commands,omitempty"` + EventLog *string `yaml:"eventLog,omitempty"` + Rotation *AuditRotation `yaml:"rotation,omitempty"` + Commands *CommandAudit `yaml:"commands,omitempty"` +} + +// AuditRotation holds audit log rotation settings. +type AuditRotation struct { + MaxBytes *int64 `yaml:"maxBytes,omitempty"` + MaxFiles *int `yaml:"maxFiles,omitempty"` } // CommandAudit holds command execution audit settings. @@ -184,9 +192,17 @@ type EffectiveEBPF struct { // EffectiveAudit is the resolved audit configuration. type EffectiveAudit struct { + EventLog string + Rotation EffectiveAuditRotation Commands EffectiveCommandAudit } +// EffectiveAuditRotation is the resolved audit log rotation configuration. +type EffectiveAuditRotation struct { + MaxBytes int64 + MaxFiles int +} + // EffectiveCommandAudit is the resolved command execution audit configuration. type EffectiveCommandAudit struct { Enabled bool diff --git a/internal/config/defaults.go b/internal/config/defaults.go index ab67c70..7f8f543 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -1,5 +1,7 @@ package config +import "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" + const ( // DefaultImageName is the published runtime image used by fresh installs. DefaultImageName = "ghcr.io/rabbitcybersec/opencode-sandbox:latest" @@ -48,6 +50,10 @@ func Defaults() EffectiveConfig { }, }, Audit: EffectiveAudit{ + Rotation: EffectiveAuditRotation{ + MaxBytes: audit.DefaultRotationMaxBytes, + MaxFiles: audit.DefaultRotationMaxFiles, + }, Commands: EffectiveCommandAudit{ Enabled: false, Backend: "ebpf", diff --git a/internal/config/load.go b/internal/config/load.go index d591495..57d21d9 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -239,12 +239,38 @@ func applyEBPF(base EffectiveEBPF, eb EBPF) EffectiveEBPF { } func applyAudit(base EffectiveAudit, audit Audit) EffectiveAudit { + if audit.EventLog != nil { + base.EventLog = *audit.EventLog + } + if audit.Rotation != nil { + base.Rotation = applyAuditRotation(base.Rotation, *audit.Rotation) + } if audit.Commands != nil { base.Commands = applyCommandAudit(base.Commands, *audit.Commands) } return base } +func applyAuditRotation(base EffectiveAuditRotation, rotation AuditRotation) EffectiveAuditRotation { + if rotation.MaxBytes != nil { + base.MaxBytes = *rotation.MaxBytes + } + if rotation.MaxFiles != nil { + base.MaxFiles = *rotation.MaxFiles + } + return base +} + +func AuditEventLogBase(cfg EffectiveConfig) string { + if cfg.Audit.EventLog != "" { + return cfg.Audit.EventLog + } + if cfg.Network.EBPF.EventLog != "" { + return cfg.Network.EBPF.EventLog + } + return cfg.Audit.Commands.EventLog +} + func applyCommandAudit(base EffectiveCommandAudit, audit CommandAudit) EffectiveCommandAudit { if audit.Enabled != nil { base.Enabled = *audit.Enabled diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 7c4e8da..6916a36 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -300,6 +300,10 @@ func TestLoadCommandAuditConfig(t *testing.T) { path := filepath.Join(dir, "config.yaml") content := `version: 1 audit: + eventLog: ~/.local/state/opencode-sandbox/audit + rotation: + maxBytes: 1024 + maxFiles: 3 commands: enabled: true backend: ebpf @@ -328,6 +332,15 @@ audit: if cfg.Audit == nil || cfg.Audit.Commands == nil { t.Fatal("expected audit.commands config") } + if cfg.Audit.EventLog == nil || *cfg.Audit.EventLog != "~/.local/state/opencode-sandbox/audit" { + t.Fatal("expected audit eventLog") + } + if cfg.Audit.Rotation == nil || cfg.Audit.Rotation.MaxBytes == nil || *cfg.Audit.Rotation.MaxBytes != 1024 { + t.Fatal("expected audit rotation maxBytes") + } + if cfg.Audit.Rotation.MaxFiles == nil || *cfg.Audit.Rotation.MaxFiles != 3 { + t.Fatal("expected audit rotation maxFiles") + } if cfg.Audit.Commands.MaxArgs == nil || *cfg.Audit.Commands.MaxArgs != 32 { t.Error("expected command audit maxArgs") } @@ -373,6 +386,11 @@ func TestMergeCommandAuditConfig(t *testing.T) { base := Defaults() overlay := File{ Audit: &Audit{ + EventLog: ptr("~/.local/state/opencode-sandbox/audit"), + Rotation: &AuditRotation{ + MaxBytes: ptr(int64(2048)), + MaxFiles: ptr(7), + }, Commands: &CommandAudit{ Enabled: ptr(false), FailClosed: ptr(true), @@ -384,6 +402,12 @@ func TestMergeCommandAuditConfig(t *testing.T) { } merged := MergeEffective(base, overlay) + if merged.Audit.EventLog != "~/.local/state/opencode-sandbox/audit" { + t.Errorf("unexpected audit eventLog: %s", merged.Audit.EventLog) + } + if merged.Audit.Rotation.MaxBytes != 2048 || merged.Audit.Rotation.MaxFiles != 7 { + t.Errorf("unexpected audit rotation: %+v", merged.Audit.Rotation) + } if merged.Audit.Commands.Enabled { t.Error("expected command audit enabled override false") } @@ -400,3 +424,25 @@ func TestMergeCommandAuditConfig(t *testing.T) { t.Error("expected command audit mirrorProjectEvents true") } } + +func TestAuditEventLogBasePrecedence(t *testing.T) { + cfg := Defaults() + if got := AuditEventLogBase(cfg); got != "" { + t.Fatalf("expected empty default event log base, got %q", got) + } + + cfg.Audit.Commands.EventLog = "commands" + if got := AuditEventLogBase(cfg); got != "commands" { + t.Fatalf("expected command audit fallback, got %q", got) + } + + cfg.Network.EBPF.EventLog = "network" + if got := AuditEventLogBase(cfg); got != "network" { + t.Fatalf("expected network ebpf eventLog to win, got %q", got) + } + + cfg.Audit.EventLog = "audit" + if got := AuditEventLogBase(cfg); got != "audit" { + t.Fatalf("expected audit eventLog to win, got %q", got) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index 10105ae..6098eb0 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -66,6 +66,12 @@ func Validate(cfg EffectiveConfig) error { } } } + if cfg.Audit.Rotation.MaxBytes < 0 { + return fmt.Errorf("invalid audit.rotation.maxBytes: %d", cfg.Audit.Rotation.MaxBytes) + } + if cfg.Audit.Rotation.MaxFiles < 0 { + return fmt.Errorf("invalid audit.rotation.maxFiles: %d", cfg.Audit.Rotation.MaxFiles) + } if cfg.Network.LocalhostAccess.Enabled { if cfg.Network.LocalhostAccess.IP == "" { return fmt.Errorf("localhostAccess.enabled requires a non-empty ip") diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go index 815390e..0a9605a 100644 --- a/internal/doctor/checks.go +++ b/internal/doctor/checks.go @@ -2,12 +2,17 @@ package doctor import ( "fmt" + "os" "os/exec" + "path/filepath" "runtime" + "sort" "strings" + "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" "github.com/RabbITCybErSeC/opencode-sandbox/internal/config" "github.com/RabbITCybErSeC/opencode-sandbox/internal/containercmd" + sandboxruntime "github.com/RabbITCybErSeC/opencode-sandbox/internal/runtime" ) // Check describes a single doctor check result. @@ -40,6 +45,8 @@ func Run(cfg config.EffectiveConfig) []Check { checkNetworkName(&checks, cfg) checkEBPFSupport(&checks, cfg) checkHostDNS(&checks, cfg) + checkAuditConfig(&checks, cfg) + checkAuditLogDir(&checks, cfg) return checks } @@ -245,6 +252,80 @@ func checkEBPFSupport(checks *[]Check, cfg config.EffectiveConfig) { }) } +func checkAuditConfig(checks *[]Check, cfg config.EffectiveConfig) { + base := config.AuditEventLogBase(cfg) + if base == "" { + base = "default state directory" + } + message := fmt.Sprintf("unified audit log writes %s with rotation maxBytes=%d maxFiles=%d", audit.DefaultFileName, cfg.Audit.Rotation.MaxBytes, cfg.Audit.Rotation.MaxFiles) + if cfg.Audit.Commands.Enabled { + message += "; command audit enabled" + } else { + message += "; command audit disabled" + } + message += fmt.Sprintf("; event log base: %s", base) + *checks = append(*checks, Check{ + ID: "audit.config", + Status: StatusPass, + Message: message, + }) +} + +func checkAuditLogDir(checks *[]Check, cfg config.EffectiveConfig) { + baseDir, err := sandboxruntime.EventLogBaseDir(config.AuditEventLogBase(cfg)) + if err != nil { + *checks = append(*checks, Check{ID: "audit.logs", Status: StatusFail, Message: fmt.Sprintf("cannot resolve audit log base: %v", err)}) + return + } + if err := os.MkdirAll(baseDir, 0755); err != nil { + *checks = append(*checks, Check{ID: "audit.logs", Status: StatusFail, Message: fmt.Sprintf("cannot create audit log base %q: %v", baseDir, err)}) + return + } + probe := filepath.Join(baseDir, ".opencode-sandbox-doctor") + if err := os.WriteFile(probe, []byte("ok"), 0644); err != nil { + *checks = append(*checks, Check{ID: "audit.logs", Status: StatusFail, Message: fmt.Sprintf("audit log base %q is not writable: %v", baseDir, err)}) + return + } + _ = os.Remove(probe) + + latest, ok := latestAuditLog(baseDir) + if !ok { + *checks = append(*checks, Check{ID: "audit.logs", Status: StatusWarn, Message: fmt.Sprintf("audit log base %q is writable, but no latest %s was found yet", baseDir, audit.DefaultFileName)}) + return + } + *checks = append(*checks, Check{ID: "audit.logs", Status: StatusPass, Message: fmt.Sprintf("latest audit log found at %s", latest)}) +} + +func latestAuditLog(baseDir string) (string, bool) { + entries, err := os.ReadDir(baseDir) + if err != nil { + return "", false + } + type candidate struct { + path string + modTime int64 + } + var candidates []candidate + for _, entry := range entries { + if !entry.IsDir() { + continue + } + path := filepath.Join(baseDir, entry.Name(), audit.DefaultFileName) + info, err := os.Stat(path) + if err != nil { + continue + } + candidates = append(candidates, candidate{path: path, modTime: info.ModTime().UnixNano()}) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].modTime > candidates[j].modTime + }) + if len(candidates) == 0 { + return "", false + } + return candidates[0].path, true +} + // IsHealthy returns true when no check is in fail state. func IsHealthy(checks []Check) bool { for _, c := range checks { diff --git a/internal/doctor/checks_test.go b/internal/doctor/checks_test.go index 4ba8db5..7f8297a 100644 --- a/internal/doctor/checks_test.go +++ b/internal/doctor/checks_test.go @@ -2,15 +2,19 @@ package doctor import ( "errors" + "os" + "path/filepath" "strings" "testing" + "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" "github.com/RabbITCybErSeC/opencode-sandbox/internal/config" ) func TestRunWithDefaults(t *testing.T) { withImageInspect(t, nil) cfg := config.Defaults() + cfg.Audit.EventLog = t.TempDir() checks := Run(cfg) if len(checks) == 0 { t.Fatal("expected checks") @@ -29,6 +33,7 @@ func TestRunWithDefaults(t *testing.T) { func TestRunWithEBPFMissingInitImage(t *testing.T) { withImageInspect(t, nil) cfg := config.Defaults() + cfg.Audit.EventLog = t.TempDir() cfg.Network.Backend = "ebpf" cfg.Network.EBPF.InitImage = "" @@ -50,6 +55,7 @@ func TestRunWithEBPFMissingInitImage(t *testing.T) { func TestRunWithDefaultCommandAuditMissingInitImageWarns(t *testing.T) { withImageInspect(t, errors.New("image not found")) cfg := config.Defaults() + cfg.Audit.EventLog = t.TempDir() cfg.Network.Mode = "practical" cfg.Network.Backend = "proxy" cfg.Audit.Commands.Enabled = true @@ -72,9 +78,45 @@ func TestRunWithDefaultCommandAuditMissingInitImageWarns(t *testing.T) { } } +func TestRunReportsMissingAuditLog(t *testing.T) { + withImageInspect(t, nil) + cfg := config.Defaults() + cfg.Audit.EventLog = t.TempDir() + + checks := Run(cfg) + check := findCheck(t, checks, "audit.logs") + if check.Status != StatusWarn { + t.Fatalf("expected warn for missing audit log, got %+v", check) + } + if !strings.Contains(check.Message, audit.DefaultFileName) { + t.Fatalf("expected audit log filename in message, got %q", check.Message) + } +} + +func TestRunReportsLatestAuditLog(t *testing.T) { + withImageInspect(t, nil) + base := t.TempDir() + runDir := filepath.Join(base, "run-1") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runDir, audit.DefaultFileName), []byte("{}\n"), 0644); err != nil { + t.Fatal(err) + } + cfg := config.Defaults() + cfg.Audit.EventLog = base + + checks := Run(cfg) + check := findCheck(t, checks, "audit.logs") + if check.Status != StatusPass { + t.Fatalf("expected pass for existing audit log, got %+v", check) + } +} + func TestRunWithEBPFMissingNetworkName(t *testing.T) { withImageInspect(t, nil) cfg := config.Defaults() + cfg.Audit.EventLog = t.TempDir() cfg.Network.Backend = "ebpf" cfg.Network.EBPF.InitImage = "opencode-sandbox-init:latest" cfg.Network.EBPF.NetworkName = "" @@ -148,3 +190,14 @@ func withImageInspect(t *testing.T, err error) { } t.Cleanup(func() { inspectDoctorImage = oldInspect }) } + +func findCheck(t *testing.T, checks []Check, id string) Check { + t.Helper() + for _, check := range checks { + if check.ID == id { + return check + } + } + t.Fatalf("expected check %s", id) + return Check{} +} diff --git a/internal/doctor/report.go b/internal/doctor/report.go index 2a6ab08..d7cf6d9 100644 --- a/internal/doctor/report.go +++ b/internal/doctor/report.go @@ -9,7 +9,7 @@ import ( func PrintReport(checks []Check, asJSON bool) { if asJSON { data, _ := json.MarshalIndent(map[string]interface{}{ - "ok": len(checks) == 0, + "ok": IsHealthy(checks), "checks": checks, }, "", " ") fmt.Println(string(data)) diff --git a/internal/integration/live_test.go b/internal/integration/live_test.go index a01943c..ab48619 100644 --- a/internal/integration/live_test.go +++ b/internal/integration/live_test.go @@ -186,16 +186,16 @@ network: // Look for event log directory under ~/.local/state/opencode-sandbox/runs. home, _ := os.UserHomeDir() - matches, err := filepath.Glob(filepath.Join(home, ".local", "state", "opencode-sandbox", "runs", "*", "network-events.jsonl")) + matches, err := filepath.Glob(filepath.Join(home, ".local", "state", "opencode-sandbox", "runs", "*", "audit-events.jsonl")) if err != nil { t.Fatalf("globbing event logs: %v", err) } if len(matches) == 0 { - t.Error("expected at least one network-events.jsonl to be created") + t.Error("expected at least one audit-events.jsonl to be created") } // Check project mirror if enabled. - projectMirror := filepath.Join(projectDir, ".opencode-sandbox", "network-events.jsonl") + projectMirror := filepath.Join(projectDir, ".opencode-sandbox", "audit-events.jsonl") if _, err := os.Stat(projectMirror); os.IsNotExist(err) { t.Log("project mirror not created (expected if container did not start successfully)") } diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 6a09129..338f4b4 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -117,14 +117,23 @@ func EventLogDir(runID string) (string, error) { // EventLogDirForBase returns the durable host event directory for a run, // optionally rooted at a configured base directory. func EventLogDirForBase(runID, baseDir string) (string, error) { + root, err := EventLogBaseDir(baseDir) + if err != nil { + return "", err + } + return filepath.Join(root, runID), nil +} + +// EventLogBaseDir returns the host directory containing per-run log dirs. +func EventLogBaseDir(baseDir string) (string, error) { if baseDir != "" { - return filepath.Join(expandHome(baseDir), runID), nil + return expandHome(baseDir), nil } stateDir, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(stateDir, ".local", "state", "opencode-sandbox", "runs", runID), nil + return filepath.Join(stateDir, ".local", "state", "opencode-sandbox", "runs"), nil } func expandHome(path string) string { diff --git a/internal/runtime/events_test.go b/internal/runtime/events_test.go index 8bbba8d..4289986 100644 --- a/internal/runtime/events_test.go +++ b/internal/runtime/events_test.go @@ -168,6 +168,16 @@ func TestEventLogDirForBaseExpandsHome(t *testing.T) { } } +func TestEventLogBaseDirDefault(t *testing.T) { + dir, err := EventLogBaseDir("") + if err != nil { + t.Fatalf("EventLogBaseDir failed: %v", err) + } + if !strings.HasSuffix(dir, filepath.Join(".local", "state", "opencode-sandbox", "runs")) { + t.Fatalf("unexpected base dir: %s", dir) + } +} + func TestEventWriterMirror(t *testing.T) { dir := t.TempDir() hostPath := filepath.Join(dir, "host.jsonl") diff --git a/internal/runtime/policy.go b/internal/runtime/policy.go index adaf1d0..4cfd28b 100644 --- a/internal/runtime/policy.go +++ b/internal/runtime/policy.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/RabbITCybErSeC/opencode-sandbox/internal/audit" "github.com/RabbITCybErSeC/opencode-sandbox/internal/config" ) @@ -53,9 +54,18 @@ type NetworkConfig struct { // AuditConfig holds audit settings in the bundle. type AuditConfig struct { + Events AuditEventsConfig `json:"events"` Commands CommandAuditConfig `json:"commands"` } +// AuditEventsConfig holds unified audit event log settings. +type AuditEventsConfig struct { + HostJsonl string `json:"hostJsonl"` + ProjectMirrorJsonl string `json:"projectMirrorJsonl"` + MirrorProjectEvents bool `json:"mirrorProjectEvents"` + Rotation audit.RotationConfig `json:"rotation"` +} + // CommandAuditConfig holds command execution audit settings. type CommandAuditConfig struct { Enabled bool `json:"enabled"` @@ -131,6 +141,15 @@ func GeneratePolicyBundle(stagingDir, runID, projectPath, projectName string, cf FailClosed: cfg.Network.FailClosed, }, Audit: AuditConfig{ + Events: AuditEventsConfig{ + HostJsonl: "/sandbox/logs/" + audit.DefaultFileName, + ProjectMirrorJsonl: "/workspace/.opencode-sandbox/" + audit.DefaultFileName, + MirrorProjectEvents: cfg.Network.EBPF.MirrorProjectEvents || cfg.Audit.Commands.MirrorProjectEvents, + Rotation: audit.RotationConfig{ + MaxBytes: cfg.Audit.Rotation.MaxBytes, + MaxFiles: cfg.Audit.Rotation.MaxFiles, + }, + }, Commands: CommandAuditConfig{ Enabled: cfg.Audit.Commands.Enabled, Backend: cfg.Audit.Commands.Backend, @@ -143,8 +162,8 @@ func GeneratePolicyBundle(stagingDir, runID, projectPath, projectName string, cf IncludeCwd: cfg.Audit.Commands.IncludeCwd, ExcludeCwd: cfg.Audit.Commands.ExcludeCwd, MirrorProjectEvents: cfg.Audit.Commands.MirrorProjectEvents, - HostJsonl: "/sandbox/logs/command-events.jsonl", - ProjectMirrorJsonl: "/workspace/.opencode-sandbox/command-events.jsonl", + HostJsonl: "/sandbox/logs/" + audit.DefaultFileName, + ProjectMirrorJsonl: "/workspace/.opencode-sandbox/" + audit.DefaultFileName, }, }, Rules: Rules{ @@ -158,8 +177,8 @@ func GeneratePolicyBundle(stagingDir, runID, projectPath, projectName string, cf TTLMaxSeconds: 300, }, Events: EventsConfig{ - HostJsonl: "/sandbox/logs/network-events.jsonl", - ProjectMirrorJsonl: "/workspace/.opencode-sandbox/network-events.jsonl", + HostJsonl: "/sandbox/logs/" + audit.DefaultFileName, + ProjectMirrorJsonl: "/workspace/.opencode-sandbox/" + audit.DefaultFileName, MirrorProjectEvents: cfg.Network.EBPF.MirrorProjectEvents, }, // Backward compatibility for the proxy. diff --git a/internal/runtime/policy_test.go b/internal/runtime/policy_test.go index a82482f..7a9952c 100644 --- a/internal/runtime/policy_test.go +++ b/internal/runtime/policy_test.go @@ -59,6 +59,10 @@ func TestGeneratePolicyBundle(t *testing.T) { }, }, Audit: config.EffectiveAudit{ + Rotation: config.EffectiveAuditRotation{ + MaxBytes: 10485760, + MaxFiles: 5, + }, Commands: config.EffectiveCommandAudit{ Enabled: true, Backend: "ebpf", @@ -126,10 +130,22 @@ func TestGeneratePolicyBundle(t *testing.T) { if bundle.Resolver.TTLMaxSeconds != 300 { t.Errorf("unexpected ttlMaxSeconds: %d", bundle.Resolver.TTLMaxSeconds) } - if bundle.Events.HostJsonl != "/sandbox/logs/network-events.jsonl" { + if bundle.Audit.Events.HostJsonl != "/sandbox/logs/audit-events.jsonl" { + t.Errorf("unexpected audit hostJsonl: %s", bundle.Audit.Events.HostJsonl) + } + if bundle.Audit.Events.ProjectMirrorJsonl != "/workspace/.opencode-sandbox/audit-events.jsonl" { + t.Errorf("unexpected audit projectMirrorJsonl: %s", bundle.Audit.Events.ProjectMirrorJsonl) + } + if !bundle.Audit.Events.MirrorProjectEvents { + t.Error("expected audit mirrorProjectEvents true") + } + if bundle.Audit.Events.Rotation.MaxBytes == 0 || bundle.Audit.Events.Rotation.MaxFiles == 0 { + t.Errorf("expected audit rotation defaults: %+v", bundle.Audit.Events.Rotation) + } + if bundle.Events.HostJsonl != "/sandbox/logs/audit-events.jsonl" { t.Errorf("unexpected hostJsonl: %s", bundle.Events.HostJsonl) } - if bundle.Events.ProjectMirrorJsonl != "/workspace/.opencode-sandbox/network-events.jsonl" { + if bundle.Events.ProjectMirrorJsonl != "/workspace/.opencode-sandbox/audit-events.jsonl" { t.Errorf("unexpected projectMirrorJsonl: %s", bundle.Events.ProjectMirrorJsonl) } if !bundle.Events.MirrorProjectEvents { @@ -147,10 +163,10 @@ func TestGeneratePolicyBundle(t *testing.T) { if bundle.Audit.Commands.LogArgs != "full" { t.Errorf("unexpected command audit logArgs: %s", bundle.Audit.Commands.LogArgs) } - if bundle.Audit.Commands.HostJsonl != "/sandbox/logs/command-events.jsonl" { + if bundle.Audit.Commands.HostJsonl != "/sandbox/logs/audit-events.jsonl" { t.Errorf("unexpected command hostJsonl: %s", bundle.Audit.Commands.HostJsonl) } - if bundle.Audit.Commands.ProjectMirrorJsonl != "/workspace/.opencode-sandbox/command-events.jsonl" { + if bundle.Audit.Commands.ProjectMirrorJsonl != "/workspace/.opencode-sandbox/audit-events.jsonl" { t.Errorf("unexpected command projectMirrorJsonl: %s", bundle.Audit.Commands.ProjectMirrorJsonl) } if len(bundle.Audit.Commands.IncludeExecutables) != 1 || bundle.Audit.Commands.IncludeExecutables[0] != "/usr/bin/curl" { From 97d363522a72daf74e61a25f421db8aad9936109 Mon Sep 17 00:00:00 2001 From: RabbITCybErSeC Date: Wed, 27 May 2026 21:25:09 +0200 Subject: [PATCH 2/3] fix logging --- Containerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Containerfile b/Containerfile index d2571e8..996343a 100644 --- a/Containerfile +++ b/Containerfile @@ -8,6 +8,7 @@ FROM golang:${GO_VERSION}-bookworm AS builder WORKDIR /build COPY go.mod go.sum ./ COPY cmd/policy-proxy ./cmd/policy-proxy +COPY internal ./internal RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o policy-proxy ./cmd/policy-proxy # --- From 44f4c4fa884cba9a6d2f7e58d39a5c711a36faad Mon Sep 17 00:00:00 2001 From: RabbITCybErSeC Date: Thu, 28 May 2026 21:00:29 +0200 Subject: [PATCH 3/3] import bug fix --- internal/cli/skills.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/cli/skills.go b/internal/cli/skills.go index 383ca52..0da936c 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -178,16 +178,34 @@ func collectSkillsList(project string) ([]skillListItem, error) { if err != nil { return nil, fmt.Errorf("getting config dir: %w", err) } + + // On macOS, os.UserConfigDir() returns ~/Library/Preferences, + // but skills may be installed at ~/.config/opencode-sandbox/skills. + // Check both locations. + home, _ := os.UserHomeDir() + xdgConfigDir := filepath.Join(home, ".config") + roots := []struct { scope string path string }{ {"global-imported", filepath.Join(configDir, "opencode-sandbox", "skills")}, + } + if xdgConfigDir != configDir { + roots = append(roots, struct { + scope string + path string + }{"global-xdg", filepath.Join(xdgConfigDir, "opencode-sandbox", "skills")}) + } + roots = append(roots, []struct { + scope string + path string + }{ {"project-imported", filepath.Join(project, ".opencode-sandbox", "skills")}, {"project-opencode", filepath.Join(project, ".opencode", "skills")}, {"project-agents", filepath.Join(project, ".agents", "skills")}, {"project-claude", filepath.Join(project, ".claude", "skills")}, - } + }...) for _, root := range roots { found, err := skills.Discover(root.path) if os.IsNotExist(err) {