Skip to content

Commit 7402e08

Browse files
authored
add interactive content keyword approval with whitelist/blacklist
- Adds deny_content_keywords policy that scans request bodies for sensitive terms and prompts users to allow or block before forwarding to LLMs. - Users can permanently whitelist or blacklist files to avoid repeated prompts. Includes desktop UI modal and headless fallback.
1 parent 4360c74 commit 7402e08

17 files changed

Lines changed: 980 additions & 102 deletions

File tree

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ policy:
120120
- "**/credentials*"
121121
- ".aws/*"
122122

123+
# Keywords that trigger interactive approval
124+
deny_content_keywords:
125+
- "CONFIDENTIAL"
126+
- "INTERNAL ONLY"
127+
123128
logging:
124129
format: json
125130
file: ~/.egressor/logs/audit.log
@@ -155,6 +160,17 @@ Glob patterns that block requests containing matching file references:
155160

156161
When a request body contains a file matching a deny pattern, Egressor returns `403` to the client and logs the blocked request — the payload never reaches the LLM.
157162

163+
### Content keywords (interactive approval)
164+
165+
When `deny_content_keywords` is set, request bodies are scanned for these keywords (case-insensitive). If a match is found, Egressor pauses the request and prompts the user in the desktop UI with four options:
166+
167+
- **Allow Once** — forward this request, don't remember
168+
- **Allow Always** — forward and add the file to the whitelist (never ask again)
169+
- **Block Once** — return 403, don't remember
170+
- **Block Always** — return 403 and add the file to the blacklist (auto-block in future)
171+
172+
The whitelist and blacklist are persisted to `config.yaml` via "Save to config". In headless mode, keyword matches are blocked by default (no UI to prompt).
173+
158174
---
159175

160176
## Desktop UI
@@ -163,7 +179,7 @@ The default mode opens a native desktop window (built with Wails + React):
163179

164180
- **Sessions tab** — live table of intercepted connections with method, host, status, file count
165181
- **Detail panel** — click a session to see full request/response headers, body (JSON-formatted), and detected files
166-
- **Policy tab** — manage allowed directories and deny file patterns, save to config
182+
- **Policy tab** — manage allowed directories, deny file patterns, content keywords, whitelist/blacklist
167183
- **Bottom bar** — proxy start/stop, pause/resume policy, session stats
168184

169185
Blocked requests are highlighted in red with the matching deny pattern shown.
@@ -186,8 +202,9 @@ Client ──TLS(egressor cert)──► Egressor ──TLS(real cert)──►
186202
6. Extracts file references from the JSON payload
187203
7. Checks file paths against `allowed_directories` — blocks if out of scope
188204
8. Checks file paths against `deny_file_patterns` — blocks if matched
189-
9. If blocked: returns `403`, logs the attempt, never forwards to the server
190-
10. If allowed: forwards the request, captures the response, logs everything
205+
9. Scans body for `deny_content_keywords` — prompts user if matched (whitelist/blacklist checked first)
206+
10. If blocked: returns `403`, logs the attempt, never forwards to the server
207+
11. If allowed: forwards the request, captures the response, logs everything
191208

192209
### File detection
193210

cmd/egressor/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ func main() {
8686
slog.Info("TLS interception enabled")
8787

8888
if *headless {
89+
interceptor.SetResolver(policy.HeadlessResolver{})
8990
server := proxy.NewServer(cfg.ListenAddress, logger, interceptor)
9091
runHeadless(server, cfg)
9192
return
@@ -96,6 +97,7 @@ func main() {
9697
sink := audit.NewMultiSink(logger, store)
9798
server := proxy.NewServer(cfg.ListenAddress, sink, interceptor)
9899
app := ui.NewApp(server, store, engine, cfg, resolvedConfig)
100+
interceptor.SetResolver(app)
99101

100102
slog.Info("egressor starting", "address", cfg.ListenAddress, "mode", "ui")
101103
if err := ui.RunUI(app); err != nil {
@@ -125,6 +127,11 @@ policy:
125127
- "**/credentials*"
126128
- ".aws/*"
127129
130+
# Keywords that trigger interactive approval before sending to LLM.
131+
# deny_content_keywords:
132+
# - "CONFIDENTIAL"
133+
# - "INTERNAL ONLY"
134+
128135
logging:
129136
format: json
130137
file: ~/.egressor/logs/audit.log

config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ policy:
1414
- "**/credentials*"
1515
- ".aws/*"
1616

17+
# Keywords that trigger interactive approval before sending to LLM.
18+
deny_content_keywords:
19+
- "CONFIDENTIAL"
20+
- "INTERNAL ONLY"
21+
1722
logging:
1823
format: json
1924
file: ~/.egressor/logs/audit.log

docs/design.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ Egressor is a local HTTPS intercepting proxy that monitors and controls outbound
2929
│ ├──▶ Check allowed_directories │
3030
│ │ └─ OUT OF SCOPE → 403 │
3131
│ ├──▶ Check deny_file_patterns │
32-
│ │ ├─ BLOCKED → 403 to client │
33-
│ │ └─ ALLOWED → forward upstream │
32+
│ │ └─ BLOCKED → 403 to client │
33+
│ ├──▶ Check deny_content_keywords │
34+
│ │ ├─ WHITELIST → auto-allow │
35+
│ │ ├─ BLACKLIST → auto-block 403 │
36+
│ │ └─ PROMPT USER → allow/block │
37+
│ │ │
38+
│ └──▶ ALLOWED → forward upstream │
3439
│ │ │
3540
│ ▼ │
3641
│ ┌──────────┐ ┌───────────────┐ │
@@ -72,6 +77,7 @@ For each connection:
7277
- Extract file references from the body
7378
- Evaluate file paths against `allowed_directories` — block if out of scope
7479
- Evaluate file paths against `deny_file_patterns` — block if matched
80+
- Scan body for `deny_content_keywords` — check whitelist/blacklist, prompt user if needed
7581
- If blocked: send 403 back to client, log, stop
7682
- If allowed: forward request to upstream, relay response back
7783
4. Record exchange in session
@@ -103,7 +109,15 @@ Two-layer policy enforcement:
103109
- Pattern matching: `filepath.Match` for globs, `**/` prefix for recursive matching, basename fallback
104110
- Runtime mutation: `GetDenyPatterns()`, `SetDenyPatterns()`, `AddDenyPattern()`, `RemoveDenyPattern()`
105111

106-
Both layers:
112+
**Content keyword approval**`EvaluateContentKeywords(body string, filePaths []string) ContentKeywordResult`:
113+
- Case-insensitive substring scan of body against `deny_content_keywords`
114+
- Partitions files into whitelist-allowed, blacklist-blocked, and needs-prompt
115+
- Interactive: pauses request, emits `content:prompt` event, waits for user decision (30s timeout)
116+
- User choices: Allow Once, Allow Always (whitelist), Block Once, Block Always (blacklist)
117+
- `PromptResolver` interface: `App` implements for UI mode, `HeadlessResolver` blocks by default
118+
- Whitelist/blacklist persisted to config via SaveConfig
119+
120+
All layers:
107121
- Pause/bypass via atomic bool (for UI toggle)
108122
- Thread-safe with `sync.RWMutex`
109123

internal/config/config.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ type InterceptConfig struct {
2424
}
2525

2626
type PolicyConfig struct {
27-
DenyFilePatterns []string `yaml:"deny_file_patterns"`
28-
AllowedDirectories []string `yaml:"allowed_directories"`
27+
DenyFilePatterns []string `yaml:"deny_file_patterns"`
28+
AllowedDirectories []string `yaml:"allowed_directories"`
29+
DenyContentKeywords []string `yaml:"deny_content_keywords"`
30+
ContentKeywordWhitelist []string `yaml:"content_keyword_whitelist"`
31+
ContentKeywordBlacklist []string `yaml:"content_keyword_blacklist"`
2932
}
3033

3134
type LogConfig struct {

internal/policy/policy.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,177 @@ func resolvePath(p string) string {
156156
return p
157157
}
158158

159+
// --- Content keyword methods ---
160+
161+
// GetDenyContentKeywords returns the current deny content keywords.
162+
func (e *Engine) GetDenyContentKeywords() []string {
163+
e.mu.RLock()
164+
defer e.mu.RUnlock()
165+
out := make([]string, len(e.cfg.DenyContentKeywords))
166+
copy(out, e.cfg.DenyContentKeywords)
167+
return out
168+
}
169+
170+
// SetDenyContentKeywords replaces all deny content keywords.
171+
func (e *Engine) SetDenyContentKeywords(keywords []string) {
172+
e.mu.Lock()
173+
defer e.mu.Unlock()
174+
e.cfg.DenyContentKeywords = make([]string, len(keywords))
175+
copy(e.cfg.DenyContentKeywords, keywords)
176+
}
177+
178+
// AddDenyContentKeyword appends a single deny content keyword.
179+
func (e *Engine) AddDenyContentKeyword(keyword string) {
180+
e.mu.Lock()
181+
defer e.mu.Unlock()
182+
e.cfg.DenyContentKeywords = append(e.cfg.DenyContentKeywords, keyword)
183+
}
184+
185+
// RemoveDenyContentKeyword removes a single deny content keyword.
186+
func (e *Engine) RemoveDenyContentKeyword(keyword string) {
187+
e.mu.Lock()
188+
defer e.mu.Unlock()
189+
filtered := e.cfg.DenyContentKeywords[:0]
190+
for _, k := range e.cfg.DenyContentKeywords {
191+
if k != keyword {
192+
filtered = append(filtered, k)
193+
}
194+
}
195+
e.cfg.DenyContentKeywords = filtered
196+
}
197+
198+
// GetContentKeywordWhitelist returns file paths that bypass content keyword checks.
199+
func (e *Engine) GetContentKeywordWhitelist() []string {
200+
e.mu.RLock()
201+
defer e.mu.RUnlock()
202+
out := make([]string, len(e.cfg.ContentKeywordWhitelist))
203+
copy(out, e.cfg.ContentKeywordWhitelist)
204+
return out
205+
}
206+
207+
// AddToContentKeywordWhitelist adds a file path to the content keyword whitelist.
208+
func (e *Engine) AddToContentKeywordWhitelist(path string) {
209+
e.mu.Lock()
210+
defer e.mu.Unlock()
211+
for _, p := range e.cfg.ContentKeywordWhitelist {
212+
if p == path {
213+
return
214+
}
215+
}
216+
e.cfg.ContentKeywordWhitelist = append(e.cfg.ContentKeywordWhitelist, path)
217+
}
218+
219+
// RemoveFromContentKeywordWhitelist removes a file path from the whitelist.
220+
func (e *Engine) RemoveFromContentKeywordWhitelist(path string) {
221+
e.mu.Lock()
222+
defer e.mu.Unlock()
223+
filtered := e.cfg.ContentKeywordWhitelist[:0]
224+
for _, p := range e.cfg.ContentKeywordWhitelist {
225+
if p != path {
226+
filtered = append(filtered, p)
227+
}
228+
}
229+
e.cfg.ContentKeywordWhitelist = filtered
230+
}
231+
232+
// GetContentKeywordBlacklist returns file paths that are always blocked by content keyword checks.
233+
func (e *Engine) GetContentKeywordBlacklist() []string {
234+
e.mu.RLock()
235+
defer e.mu.RUnlock()
236+
out := make([]string, len(e.cfg.ContentKeywordBlacklist))
237+
copy(out, e.cfg.ContentKeywordBlacklist)
238+
return out
239+
}
240+
241+
// AddToContentKeywordBlacklist adds a file path to the content keyword blacklist.
242+
func (e *Engine) AddToContentKeywordBlacklist(path string) {
243+
e.mu.Lock()
244+
defer e.mu.Unlock()
245+
for _, p := range e.cfg.ContentKeywordBlacklist {
246+
if p == path {
247+
return
248+
}
249+
}
250+
e.cfg.ContentKeywordBlacklist = append(e.cfg.ContentKeywordBlacklist, path)
251+
}
252+
253+
// RemoveFromContentKeywordBlacklist removes a file path from the blacklist.
254+
func (e *Engine) RemoveFromContentKeywordBlacklist(path string) {
255+
e.mu.Lock()
256+
defer e.mu.Unlock()
257+
filtered := e.cfg.ContentKeywordBlacklist[:0]
258+
for _, p := range e.cfg.ContentKeywordBlacklist {
259+
if p != path {
260+
filtered = append(filtered, p)
261+
}
262+
}
263+
e.cfg.ContentKeywordBlacklist = filtered
264+
}
265+
266+
// ContentKeywordResult holds the outcome of a content keyword evaluation.
267+
type ContentKeywordResult struct {
268+
HasMatch bool
269+
MatchedKeyword string
270+
AutoAllowed []string // file paths resolved by whitelist
271+
AutoBlocked []string // file paths resolved by blacklist
272+
NeedPrompt []string // file paths needing user decision
273+
}
274+
275+
// EvaluateContentKeywords scans the body for deny_content_keywords and partitions
276+
// detected file paths into whitelist-allowed, blacklist-blocked, and needs-prompt.
277+
func (e *Engine) EvaluateContentKeywords(body string, filePaths []string) ContentKeywordResult {
278+
if e.bypassed.Load() {
279+
return ContentKeywordResult{}
280+
}
281+
282+
e.mu.RLock()
283+
keywords := make([]string, len(e.cfg.DenyContentKeywords))
284+
copy(keywords, e.cfg.DenyContentKeywords)
285+
whitelist := make(map[string]bool, len(e.cfg.ContentKeywordWhitelist))
286+
for _, p := range e.cfg.ContentKeywordWhitelist {
287+
whitelist[p] = true
288+
}
289+
blacklist := make(map[string]bool, len(e.cfg.ContentKeywordBlacklist))
290+
for _, p := range e.cfg.ContentKeywordBlacklist {
291+
blacklist[p] = true
292+
}
293+
e.mu.RUnlock()
294+
295+
if len(keywords) == 0 {
296+
return ContentKeywordResult{}
297+
}
298+
299+
// Case-insensitive keyword scan
300+
bodyLower := strings.ToLower(body)
301+
var matchedKeyword string
302+
for _, kw := range keywords {
303+
if strings.Contains(bodyLower, strings.ToLower(kw)) {
304+
matchedKeyword = kw
305+
break
306+
}
307+
}
308+
if matchedKeyword == "" {
309+
return ContentKeywordResult{}
310+
}
311+
312+
// Partition file paths by whitelist/blacklist
313+
result := ContentKeywordResult{
314+
HasMatch: true,
315+
MatchedKeyword: matchedKeyword,
316+
}
317+
for _, fp := range filePaths {
318+
switch {
319+
case whitelist[fp]:
320+
result.AutoAllowed = append(result.AutoAllowed, fp)
321+
case blacklist[fp]:
322+
result.AutoBlocked = append(result.AutoBlocked, fp)
323+
default:
324+
result.NeedPrompt = append(result.NeedPrompt, fp)
325+
}
326+
}
327+
return result
328+
}
329+
159330
// EvaluateFiles checks if any detected file paths match deny_file_patterns.
160331
func (e *Engine) EvaluateFiles(paths []string) Decision {
161332
if e.bypassed.Load() {

0 commit comments

Comments
 (0)