Skip to content

Commit 4a319d1

Browse files
authored
fix(memory+guard): quarantine scan-rejected atoms and fix socket_path transport (#87)
Two follow-ups to the PIGuard false-positive investigation (#85): 1. Scan-rejected atoms no longer vanish. The guard scan in addAtom ran before quarantine routing and hard-dropped rejections, so any guard false positive silently destroyed a legitimate memory with no review path. Rejections now go to quarantine with a recorded reason ("scan_rejected: ..." alongside "tainted"), visible via odek memory extended quarantine. PromoteAtom skips the guard rescan: the human review is the approval, and a rescan would reject the same false positive again. The extractor no longer pre-scans atoms - the single scan gate is at persistence, so extraction and manual adds get identical quarantine-on-reject behavior. 2. guard.socket_path actually works now. The piguard client dialed the Unix socket but spoke HTTP, while the go-prompt-injection-guard daemon speaks newline-delimited JSON - every scan errored and fallback_to_local silently degraded to local-only scanning. Socket mode now forwards the request as one NDJSON line per call (matching the daemon protocol), surfaces {"error":...} daemon replies, and is covered by fake-daemon socket tests plus an E2E run against the real daemon container (BENIGN facts pass, injections rejected, batch/long work).
1 parent 8275f7f commit 4a319d1

10 files changed

Lines changed: 414 additions & 92 deletions

File tree

cmd/odek/memory_cmd.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,22 @@ func extendedMemoryCmd(dir string, args []string) error {
121121
return nil
122122

123123
case "quarantine":
124-
atoms, err := em.ListQuarantine()
124+
entries, err := em.ListQuarantineEntries()
125125
if err != nil {
126126
return err
127127
}
128-
if len(atoms) == 0 {
128+
if len(entries) == 0 {
129129
fmt.Println("No atoms in quarantine.")
130130
return nil
131131
}
132-
fmt.Printf("%d atom(s) in quarantine (excluded from recall):\n\n", len(atoms))
133-
for _, a := range atoms {
134-
fmt.Printf("• %s [%s] %s\n", a.ID, a.SourceClass, truncate(a.Text, 120))
132+
fmt.Printf("%d atom(s) in quarantine (excluded from recall):\n\n", len(entries))
133+
for _, e := range entries {
134+
fmt.Printf("• %s [%s] %s\n", e.ID, e.SourceClass, truncate(e.Text, 120))
135+
if e.Reason != "" {
136+
fmt.Printf(" reason: %s\n", truncate(e.Reason, 120))
137+
}
135138
}
139+
fmt.Println("\nReview and restore with: odek memory extended promote <atom_id>")
136140
return nil
137141

138142
case "compact":

docs/CONFIG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,8 @@ The guard is **off by default** in the sense that no sidecar is needed; the loca
190190
| `url` | `""` | Single-text detection endpoint (e.g. `http://127.0.0.1:8080/detect`) |
191191
| `batch_url` | `""` | Batch detection endpoint; if unset, derived from `url` by substituting the endpoint path |
192192
| `long_url` | `""` | Long-text detection endpoint; if unset, derived from `url` by substituting the endpoint path |
193-
| `socket_path` | `""` | Unix socket path for the sidecar (alternative to `url`) |
194-
| `threshold` | `0.9` | Score above which content is treated as injected |
193+
| `socket_path` | `""` | Unix socket of the piguard daemon (alternative to `url`); speaks the daemon's native newline-delimited JSON protocol directly, no HTTP gateway needed |
194+
| `threshold` | `0.9` | Confidence above which an `INJECTION` verdict is treated as injected. The sidecar score is the confidence of the predicted label, so the threshold never applies to `BENIGN` results |
195195
| `timeout_seconds` | `5` | Per-request timeout |
196196
| `fallback_to_local` | `true` | If the sidecar fails, fall back to the local rule scan |
197197
| `max_text_length` | `0` | Truncate text sent to the sidecar; `0` means no limit. The local scan still sees the full text |

docs/EXTENDED_MEMORY.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,18 +269,20 @@ Loaded and summarized user-model values are scanned for injection patterns; any
269269

270270
Atoms with a tainted `source_class` (`tool_output`, `file_read`, `web`, `mcp`, `subagent`, `agent_generated`, `inferred`) are stored in `extended/quarantine.json` instead of the live atom corpus.
271271

272+
Atoms that fail the injection guard scan at persistence time are also quarantined rather than dropped, so guard false positives can be reviewed and restored instead of silently lost. Each quarantine entry records a `reason`: `tainted` for untrusted source classes, or `scan_rejected: ...` with the guard's verdict.
273+
272274
A quarantined atom has the same schema as a trusted atom but:
273275

274276
- `source_class` is one of the tainted classes.
275277
- `trust_boost` is 0.
276278
- It is excluded from semantic search.
277279
- It is subject to `quarantine_ttl_days` and may be auto-deleted.
278280

279-
Per-turn extraction only produces `user_said` atoms, so normal user messages do not land in quarantine. Quarantine is used when an atom is explicitly added (via the tool/API or programmatically) with a tainted source class, or when an `inferred` atom is produced by a future flow.
281+
Per-turn extraction only produces `user_said` atoms, so normal user messages do not land in quarantine. Quarantine is used when an atom is explicitly added (via the tool/API or programmatically) with a tainted source class, when the guard scan rejects an atom, or when an `inferred` atom is produced by a future flow.
280282

281283
Promotion from quarantine to the live store is implemented and human-gated:
282284

283-
- `odek memory extended promote <atom-id>` moves the atom to the live store with `source_class: user_approved`.
285+
- `odek memory extended promote <atom-id>` moves the atom to the live store with `source_class: user_approved`. The guard rescan is skipped on promote — the human review is the approval, and a rescan would reject guard false positives again.
284286
- `odek memory extended pin <atom-id>` pins a live atom so it is never evicted.
285287

286288
## Size Cap and Eviction

internal/guard/piguard.go

Lines changed: 79 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package guard
22

33
import (
4+
"bufio"
45
"bytes"
56
"context"
67
"encoding/json"
@@ -14,26 +15,32 @@ import (
1415
)
1516

1617
// piguardClient is a Guard implementation that calls a go-prompt-injection-guard
17-
// sidecar over HTTP or a Unix domain socket.
18+
// sidecar. With url it speaks HTTP JSON (via the docker HTTP gateway); with
19+
// socket_path it speaks the daemon's native newline-delimited JSON protocol
20+
// directly over the Unix socket.
1821
type piguardClient struct {
1922
cfg *Config
2023
client *http.Client
24+
socketPath string
2125
detectURL string
2226
longURL string
2327
batchURL string
2428
}
2529

26-
// detectRequest is the body for POST /detect.
30+
// detectRequest is the body for POST /detect (HTTP) or a {"text":...} daemon
31+
// line (socket).
2732
type detectRequest struct {
2833
Text string `json:"text"`
2934
}
3035

31-
// batchRequest is the body for POST /raw.
36+
// batchRequest is the body for POST /raw (HTTP) or a {"texts":[...]} daemon
37+
// line (socket).
3238
type batchRequest struct {
3339
Texts []string `json:"texts"`
3440
}
3541

36-
// longRequest is the body for POST /long.
42+
// longRequest is the body for POST /long (HTTP) or a {"long":...} daemon
43+
// line (socket).
3744
type longRequest struct {
3845
Long string `json:"long"`
3946
}
@@ -44,6 +51,11 @@ type detectResponse struct {
4451
Score float64 `json:"score"`
4552
}
4653

54+
// errorResponse is the daemon's reply to a malformed request line.
55+
type errorResponse struct {
56+
Error string `json:"error"`
57+
}
58+
4759
// batchResponse is the response body for POST /raw.
4860
type batchResponse struct {
4961
Results []detectResponse `json:"results"`
@@ -58,25 +70,13 @@ func newPiguardClient(cfg *Config) (Guard, error) {
5870
return nil, fmt.Errorf("piguard requires url or socket_path")
5971
}
6072

61-
timeout := timeout(cfg)
62-
transport := http.DefaultTransport.(*http.Transport).Clone()
63-
64-
var client *http.Client
65-
if cfg.SocketPath != "" {
66-
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
67-
return net.Dial("unix", cfg.SocketPath)
68-
}
69-
client = &http.Client{Timeout: timeout, Transport: transport}
70-
} else {
71-
client = &http.Client{Timeout: timeout}
72-
}
73-
7473
return &piguardClient{
75-
cfg: cfg,
76-
client: client,
77-
detectURL: endpoint(cfg, "detect"),
78-
longURL: endpoint(cfg, "long"),
79-
batchURL: endpoint(cfg, "raw"),
74+
cfg: cfg,
75+
client: &http.Client{Timeout: timeout(cfg)},
76+
socketPath: cfg.SocketPath,
77+
detectURL: endpoint(cfg, "detect"),
78+
longURL: endpoint(cfg, "long"),
79+
batchURL: endpoint(cfg, "raw"),
8080
}, nil
8181
}
8282

@@ -110,7 +110,7 @@ func endpoint(cfg *Config, name string) string {
110110
return u.String()
111111
}
112112

113-
// Detect calls POST /detect.
113+
// Detect classifies a single text.
114114
func (p *piguardClient) Detect(ctx context.Context, text string) (Result, error) {
115115
start := time.Now()
116116
payload := detectRequest{Text: truncateForGuard(text, p.cfg)}
@@ -119,20 +119,19 @@ func (p *piguardClient) Detect(ctx context.Context, text string) (Result, error)
119119
return Result{}, fmt.Errorf("marshal detect request: %w", err)
120120
}
121121

122-
resp, err := p.post(ctx, p.detectURL, body)
122+
resp, err := p.rpc(ctx, p.detectURL, body)
123123
if err != nil {
124124
return Result{}, err
125125
}
126-
defer resp.Body.Close()
127126

128127
var dr detectResponse
129-
if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil {
128+
if err := json.Unmarshal(resp, &dr); err != nil {
130129
return Result{}, fmt.Errorf("decode detect response: %w", err)
131130
}
132131
return resultFromResponse(dr, start, threshold(p.cfg)), nil
133132
}
134133

135-
// DetectBatch calls POST /raw.
134+
// DetectBatch classifies many texts in one round-trip.
136135
func (p *piguardClient) DetectBatch(ctx context.Context, texts []string) ([]Result, error) {
137136
start := time.Now()
138137
truncated := make([]string, len(texts))
@@ -145,14 +144,13 @@ func (p *piguardClient) DetectBatch(ctx context.Context, texts []string) ([]Resu
145144
return nil, fmt.Errorf("marshal batch request: %w", err)
146145
}
147146

148-
resp, err := p.post(ctx, p.batchURL, body)
147+
resp, err := p.rpc(ctx, p.batchURL, body)
149148
if err != nil {
150149
return nil, err
151150
}
152-
defer resp.Body.Close()
153151

154152
var br batchResponse
155-
if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
153+
if err := json.Unmarshal(resp, &br); err != nil {
156154
return nil, fmt.Errorf("decode batch response: %w", err)
157155
}
158156

@@ -164,7 +162,7 @@ func (p *piguardClient) DetectBatch(ctx context.Context, texts []string) ([]Resu
164162
return results, nil
165163
}
166164

167-
// DetectLong calls POST /long.
165+
// DetectLong scans a document larger than the model's token window in full.
168166
func (p *piguardClient) DetectLong(ctx context.Context, text string) (Result, error) {
169167
start := time.Now()
170168
payload := longRequest{Long: truncateForGuard(text, p.cfg)}
@@ -173,14 +171,13 @@ func (p *piguardClient) DetectLong(ctx context.Context, text string) (Result, er
173171
return Result{}, fmt.Errorf("marshal long request: %w", err)
174172
}
175173

176-
resp, err := p.post(ctx, p.longURL, body)
174+
resp, err := p.rpc(ctx, p.longURL, body)
177175
if err != nil {
178176
return Result{}, err
179177
}
180-
defer resp.Body.Close()
181178

182179
var dr detectResponse
183-
if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil {
180+
if err := json.Unmarshal(resp, &dr); err != nil {
184181
return Result{}, fmt.Errorf("decode long response: %w", err)
185182
}
186183
return resultFromResponse(dr, start, threshold(p.cfg)), nil
@@ -189,8 +186,52 @@ func (p *piguardClient) DetectLong(ctx context.Context, text string) (Result, er
189186
// Close is a no-op for the HTTP client.
190187
func (p *piguardClient) Close() error { return nil }
191188

192-
// post sends a JSON POST request and returns the response.
193-
func (p *piguardClient) post(ctx context.Context, urlStr string, body []byte) (*http.Response, error) {
189+
// rpc sends one request payload to the sidecar and returns the raw response
190+
// body. In socket mode it speaks the daemon's native newline-delimited JSON
191+
// protocol over the Unix socket; otherwise it POSTs the payload as JSON to
192+
// the given HTTP endpoint.
193+
func (p *piguardClient) rpc(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
194+
var resp []byte
195+
var err error
196+
if p.socketPath != "" {
197+
resp, err = p.rpcSocket(body)
198+
} else {
199+
resp, err = p.rpcHTTP(ctx, endpoint, body)
200+
}
201+
if err != nil {
202+
return nil, err
203+
}
204+
// The daemon answers malformed lines with {"error": "..."} instead of a
205+
// classification; surface it rather than decoding empty label/score.
206+
var er errorResponse
207+
if jsonErr := json.Unmarshal(resp, &er); jsonErr == nil && er.Error != "" {
208+
return nil, fmt.Errorf("piguard daemon error: %s", er.Error)
209+
}
210+
return resp, nil
211+
}
212+
213+
// rpcSocket forwards the payload as one newline-delimited JSON line to the
214+
// daemon's Unix socket and returns its single-line reply.
215+
func (p *piguardClient) rpcSocket(body []byte) ([]byte, error) {
216+
conn, err := net.DialTimeout("unix", p.socketPath, 2*time.Second)
217+
if err != nil {
218+
return nil, fmt.Errorf("dial piguard socket: %w", err)
219+
}
220+
defer conn.Close()
221+
_ = conn.SetDeadline(time.Now().Add(timeout(p.cfg)))
222+
223+
if _, err := conn.Write(append(bytes.TrimRight(body, "\n"), '\n')); err != nil {
224+
return nil, fmt.Errorf("write piguard socket: %w", err)
225+
}
226+
resp, err := bufio.NewReader(conn).ReadBytes('\n')
227+
if err != nil && len(resp) == 0 {
228+
return nil, fmt.Errorf("read piguard socket: %w", err)
229+
}
230+
return bytes.TrimSpace(resp), nil
231+
}
232+
233+
// rpcHTTP sends a JSON POST request and returns the response body.
234+
func (p *piguardClient) rpcHTTP(ctx context.Context, urlStr string, body []byte) ([]byte, error) {
194235
req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
195236
if err != nil {
196237
return nil, fmt.Errorf("create request: %w", err)
@@ -201,12 +242,12 @@ func (p *piguardClient) post(ctx context.Context, urlStr string, body []byte) (*
201242
if err != nil {
202243
return nil, err
203244
}
245+
defer resp.Body.Close()
204246
if resp.StatusCode != http.StatusOK {
205247
_, _ = io.Copy(io.Discard, resp.Body)
206-
resp.Body.Close()
207248
return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, urlStr)
208249
}
209-
return resp, nil
250+
return io.ReadAll(resp.Body)
210251
}
211252

212253
// resultFromResponse converts a PIGuard response into a Result, applying the

0 commit comments

Comments
 (0)