Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ policy:
- "**/credentials*"
- ".aws/*"

# Keywords that trigger interactive approval
deny_content_keywords:
- "CONFIDENTIAL"
- "INTERNAL ONLY"

logging:
format: json
file: ~/.egressor/logs/audit.log
Expand Down Expand Up @@ -155,6 +160,17 @@ Glob patterns that block requests containing matching file references:

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.

### Content keywords (interactive approval)

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:

- **Allow Once** — forward this request, don't remember
- **Allow Always** — forward and add the file to the whitelist (never ask again)
- **Block Once** — return 403, don't remember
- **Block Always** — return 403 and add the file to the blacklist (auto-block in future)

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).

---

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

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

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

### File detection

Expand Down
7 changes: 7 additions & 0 deletions cmd/egressor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func main() {
slog.Info("TLS interception enabled")

if *headless {
interceptor.SetResolver(policy.HeadlessResolver{})
server := proxy.NewServer(cfg.ListenAddress, logger, interceptor)
runHeadless(server, cfg)
return
Expand All @@ -96,6 +97,7 @@ func main() {
sink := audit.NewMultiSink(logger, store)
server := proxy.NewServer(cfg.ListenAddress, sink, interceptor)
app := ui.NewApp(server, store, engine, cfg, resolvedConfig)
interceptor.SetResolver(app)

slog.Info("egressor starting", "address", cfg.ListenAddress, "mode", "ui")
if err := ui.RunUI(app); err != nil {
Expand Down Expand Up @@ -125,6 +127,11 @@ policy:
- "**/credentials*"
- ".aws/*"

# Keywords that trigger interactive approval before sending to LLM.
# deny_content_keywords:
# - "CONFIDENTIAL"
# - "INTERNAL ONLY"

logging:
format: json
file: ~/.egressor/logs/audit.log
Expand Down
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ policy:
- "**/credentials*"
- ".aws/*"

# Keywords that trigger interactive approval before sending to LLM.
deny_content_keywords:
- "CONFIDENTIAL"
- "INTERNAL ONLY"

logging:
format: json
file: ~/.egressor/logs/audit.log
Expand Down
20 changes: 17 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ Egressor is a local HTTPS intercepting proxy that monitors and controls outbound
│ ├──▶ Check allowed_directories │
│ │ └─ OUT OF SCOPE → 403 │
│ ├──▶ Check deny_file_patterns │
│ │ ├─ BLOCKED → 403 to client │
│ │ └─ ALLOWED → forward upstream │
│ │ └─ BLOCKED → 403 to client │
│ ├──▶ Check deny_content_keywords │
│ │ ├─ WHITELIST → auto-allow │
│ │ ├─ BLACKLIST → auto-block 403 │
│ │ └─ PROMPT USER → allow/block │
│ │ │
│ └──▶ ALLOWED → forward upstream │
│ │ │
│ ▼ │
│ ┌──────────┐ ┌───────────────┐ │
Expand Down Expand Up @@ -72,6 +77,7 @@ For each connection:
- Extract file references from the body
- Evaluate file paths against `allowed_directories` — block if out of scope
- Evaluate file paths against `deny_file_patterns` — block if matched
- Scan body for `deny_content_keywords` — check whitelist/blacklist, prompt user if needed
- If blocked: send 403 back to client, log, stop
- If allowed: forward request to upstream, relay response back
4. Record exchange in session
Expand Down Expand Up @@ -103,7 +109,15 @@ Two-layer policy enforcement:
- Pattern matching: `filepath.Match` for globs, `**/` prefix for recursive matching, basename fallback
- Runtime mutation: `GetDenyPatterns()`, `SetDenyPatterns()`, `AddDenyPattern()`, `RemoveDenyPattern()`

Both layers:
**Content keyword approval** — `EvaluateContentKeywords(body string, filePaths []string) ContentKeywordResult`:
- Case-insensitive substring scan of body against `deny_content_keywords`
- Partitions files into whitelist-allowed, blacklist-blocked, and needs-prompt
- Interactive: pauses request, emits `content:prompt` event, waits for user decision (30s timeout)
- User choices: Allow Once, Allow Always (whitelist), Block Once, Block Always (blacklist)
- `PromptResolver` interface: `App` implements for UI mode, `HeadlessResolver` blocks by default
- Whitelist/blacklist persisted to config via SaveConfig

All layers:
- Pause/bypass via atomic bool (for UI toggle)
- Thread-safe with `sync.RWMutex`

Expand Down
7 changes: 5 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ type InterceptConfig struct {
}

type PolicyConfig struct {
DenyFilePatterns []string `yaml:"deny_file_patterns"`
AllowedDirectories []string `yaml:"allowed_directories"`
DenyFilePatterns []string `yaml:"deny_file_patterns"`
AllowedDirectories []string `yaml:"allowed_directories"`
DenyContentKeywords []string `yaml:"deny_content_keywords"`
ContentKeywordWhitelist []string `yaml:"content_keyword_whitelist"`
ContentKeywordBlacklist []string `yaml:"content_keyword_blacklist"`
}

type LogConfig struct {
Expand Down
171 changes: 171 additions & 0 deletions internal/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,177 @@ func resolvePath(p string) string {
return p
}

// --- Content keyword methods ---

// GetDenyContentKeywords returns the current deny content keywords.
func (e *Engine) GetDenyContentKeywords() []string {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]string, len(e.cfg.DenyContentKeywords))
copy(out, e.cfg.DenyContentKeywords)
return out
}

// SetDenyContentKeywords replaces all deny content keywords.
func (e *Engine) SetDenyContentKeywords(keywords []string) {
e.mu.Lock()
defer e.mu.Unlock()
e.cfg.DenyContentKeywords = make([]string, len(keywords))
copy(e.cfg.DenyContentKeywords, keywords)
}

// AddDenyContentKeyword appends a single deny content keyword.
func (e *Engine) AddDenyContentKeyword(keyword string) {
e.mu.Lock()
defer e.mu.Unlock()
e.cfg.DenyContentKeywords = append(e.cfg.DenyContentKeywords, keyword)
}

// RemoveDenyContentKeyword removes a single deny content keyword.
func (e *Engine) RemoveDenyContentKeyword(keyword string) {
e.mu.Lock()
defer e.mu.Unlock()
filtered := e.cfg.DenyContentKeywords[:0]
for _, k := range e.cfg.DenyContentKeywords {
if k != keyword {
filtered = append(filtered, k)
}
}
e.cfg.DenyContentKeywords = filtered
}

// GetContentKeywordWhitelist returns file paths that bypass content keyword checks.
func (e *Engine) GetContentKeywordWhitelist() []string {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]string, len(e.cfg.ContentKeywordWhitelist))
copy(out, e.cfg.ContentKeywordWhitelist)
return out
}

// AddToContentKeywordWhitelist adds a file path to the content keyword whitelist.
func (e *Engine) AddToContentKeywordWhitelist(path string) {
e.mu.Lock()
defer e.mu.Unlock()
for _, p := range e.cfg.ContentKeywordWhitelist {
if p == path {
return
}
}
e.cfg.ContentKeywordWhitelist = append(e.cfg.ContentKeywordWhitelist, path)
}

// RemoveFromContentKeywordWhitelist removes a file path from the whitelist.
func (e *Engine) RemoveFromContentKeywordWhitelist(path string) {
e.mu.Lock()
defer e.mu.Unlock()
filtered := e.cfg.ContentKeywordWhitelist[:0]
for _, p := range e.cfg.ContentKeywordWhitelist {
if p != path {
filtered = append(filtered, p)
}
}
e.cfg.ContentKeywordWhitelist = filtered
}

// GetContentKeywordBlacklist returns file paths that are always blocked by content keyword checks.
func (e *Engine) GetContentKeywordBlacklist() []string {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]string, len(e.cfg.ContentKeywordBlacklist))
copy(out, e.cfg.ContentKeywordBlacklist)
return out
}

// AddToContentKeywordBlacklist adds a file path to the content keyword blacklist.
func (e *Engine) AddToContentKeywordBlacklist(path string) {
e.mu.Lock()
defer e.mu.Unlock()
for _, p := range e.cfg.ContentKeywordBlacklist {
if p == path {
return
}
}
e.cfg.ContentKeywordBlacklist = append(e.cfg.ContentKeywordBlacklist, path)
}

// RemoveFromContentKeywordBlacklist removes a file path from the blacklist.
func (e *Engine) RemoveFromContentKeywordBlacklist(path string) {
e.mu.Lock()
defer e.mu.Unlock()
filtered := e.cfg.ContentKeywordBlacklist[:0]
for _, p := range e.cfg.ContentKeywordBlacklist {
if p != path {
filtered = append(filtered, p)
}
}
e.cfg.ContentKeywordBlacklist = filtered
}

// ContentKeywordResult holds the outcome of a content keyword evaluation.
type ContentKeywordResult struct {
HasMatch bool
MatchedKeyword string
AutoAllowed []string // file paths resolved by whitelist
AutoBlocked []string // file paths resolved by blacklist
NeedPrompt []string // file paths needing user decision
}

// EvaluateContentKeywords scans the body for deny_content_keywords and partitions
// detected file paths into whitelist-allowed, blacklist-blocked, and needs-prompt.
func (e *Engine) EvaluateContentKeywords(body string, filePaths []string) ContentKeywordResult {
if e.bypassed.Load() {
return ContentKeywordResult{}
}

e.mu.RLock()
keywords := make([]string, len(e.cfg.DenyContentKeywords))
copy(keywords, e.cfg.DenyContentKeywords)
whitelist := make(map[string]bool, len(e.cfg.ContentKeywordWhitelist))
for _, p := range e.cfg.ContentKeywordWhitelist {
whitelist[p] = true
}
blacklist := make(map[string]bool, len(e.cfg.ContentKeywordBlacklist))
for _, p := range e.cfg.ContentKeywordBlacklist {
blacklist[p] = true
}
e.mu.RUnlock()

if len(keywords) == 0 {
return ContentKeywordResult{}
}

// Case-insensitive keyword scan
bodyLower := strings.ToLower(body)
var matchedKeyword string
for _, kw := range keywords {
if strings.Contains(bodyLower, strings.ToLower(kw)) {
matchedKeyword = kw
break
}
}
if matchedKeyword == "" {
return ContentKeywordResult{}
}

// Partition file paths by whitelist/blacklist
result := ContentKeywordResult{
HasMatch: true,
MatchedKeyword: matchedKeyword,
}
for _, fp := range filePaths {
switch {
case whitelist[fp]:
result.AutoAllowed = append(result.AutoAllowed, fp)
case blacklist[fp]:
result.AutoBlocked = append(result.AutoBlocked, fp)
default:
result.NeedPrompt = append(result.NeedPrompt, fp)
}
}
return result
}

// EvaluateFiles checks if any detected file paths match deny_file_patterns.
func (e *Engine) EvaluateFiles(paths []string) Decision {
if e.bypassed.Load() {
Expand Down
Loading
Loading