Skip to content

Commit a3d5a36

Browse files
committed
fix(gitignore): scope audit/ pattern to repo root, not the source pkg
The unanchored 'audit/' pattern matched internal/audit/ too, so the JSONL writer that the session loop calls was uncommitted on origin. CI (and the v2026.5.22.0 release build) failed with 'no required module provides package github.com/RealWhyKnot/Handoff/internal/audit'. Anchor the runtime-log ignore to /audit/ at the repo root and add the source file that was always meant to be tracked.
1 parent eaef7f9 commit a3d5a36

2 files changed

Lines changed: 99 additions & 2 deletions

File tree

.gitignore

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
# Go module cache / vendor
1111
vendor/
1212

13-
# Audit logs (local only)
14-
audit/
13+
# JSONL audit logs that handoff writes at runtime under
14+
# %PROGRAMDATA%\whyknot\handoff\audit\. Never committed to the repo;
15+
# the literal-anchor /audit/ matches the root only and leaves
16+
# internal/audit/ (the source package) alone.
17+
/audit/
1518

1619
# Temp unpacked tools
1720
.handoff-bin/

internal/audit/audit.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
//
3+
// Package audit writes a JSONL log of every command the host runs as
4+
// part of a session. The host can review or share the log to verify
5+
// what the operator did. One file per day; append-only.
6+
7+
package audit
8+
9+
import (
10+
"encoding/json"
11+
"fmt"
12+
"os"
13+
"path/filepath"
14+
"sync"
15+
"time"
16+
)
17+
18+
// Entry is one row in the audit log.
19+
type Entry struct {
20+
Ts string `json:"ts"` // RFC3339 with millis, UTC
21+
SessionID string `json:"sid"` // first 8 chars of view token
22+
Operator string `json:"op"` // remote ip seen at session start, may be empty
23+
Capability string `json:"cap"` // command kind
24+
Args interface{} `json:"args"` // command extras (may be trimmed for very large payloads)
25+
Consent string `json:"consent"` // session | prompt_allow | prompt_deny | auto
26+
Result string `json:"result"` // ok | err | denied
27+
ElapsedMs int64 `json:"elapsed_ms"`
28+
Detail string `json:"detail,omitempty"`
29+
}
30+
31+
// Logger is goroutine-safe; multiple capability handlers writing
32+
// concurrently won't tear lines.
33+
type Logger struct {
34+
mu sync.Mutex
35+
dir string
36+
f *os.File
37+
day string
38+
}
39+
40+
// New returns a Logger backed by the standard %PROGRAMDATA% audit dir.
41+
// If PROGRAMDATA is unset (rare), falls back to %TEMP%.
42+
func New() (*Logger, error) {
43+
root := os.Getenv("PROGRAMDATA")
44+
if root == "" {
45+
root = os.TempDir()
46+
}
47+
dir := filepath.Join(root, "whyknot", "handoff", "audit")
48+
if err := os.MkdirAll(dir, 0o755); err != nil {
49+
return nil, fmt.Errorf("mkdir audit dir: %w", err)
50+
}
51+
return &Logger{dir: dir}, nil
52+
}
53+
54+
// Write appends one entry. Ts is filled in if empty.
55+
func (l *Logger) Write(e Entry) error {
56+
if e.Ts == "" {
57+
e.Ts = time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
58+
}
59+
l.mu.Lock()
60+
defer l.mu.Unlock()
61+
62+
day := time.Now().UTC().Format("2006-01-02")
63+
if l.f == nil || l.day != day {
64+
if l.f != nil {
65+
_ = l.f.Close()
66+
}
67+
path := filepath.Join(l.dir, "handoff-"+day+".jsonl")
68+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
69+
if err != nil {
70+
return fmt.Errorf("open audit file: %w", err)
71+
}
72+
l.f = f
73+
l.day = day
74+
}
75+
b, err := json.Marshal(e)
76+
if err != nil {
77+
return err
78+
}
79+
b = append(b, '\n')
80+
_, err = l.f.Write(b)
81+
return err
82+
}
83+
84+
// Close flushes and closes the underlying file.
85+
func (l *Logger) Close() error {
86+
l.mu.Lock()
87+
defer l.mu.Unlock()
88+
if l.f != nil {
89+
err := l.f.Close()
90+
l.f = nil
91+
return err
92+
}
93+
return nil
94+
}

0 commit comments

Comments
 (0)