You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(skills): harden auto-learn pipeline and enable skills guard scan (#89)
Makes the skill auto-learn system safe to use by default:
1. Local-scan floor: ScanSuggestionBody, scanSkill, and applyGuardToSkills
now always run the local rule scan via guard.ScanContentWithScope -
previously 'skills scan disabled' (the default everywhere) meant zero
scanning, so a learned skill body containing injection patterns saved
and loaded unchecked.
2. NeedsReview is now enforced at trigger time: lazy matchers are built
from the non-NeedsReview skill set, so flagged/tainted skills can no
longer be trigger-injected on a single keyword match. Previously
NeedsReview only pinned skills out of auto-load, leaving per-turn
injection wide open.
3. Project-dir skills (./.odek/skills) are distrusted like ./odek.json:
forced NeedsReview with a 'project' source, so a cloned repo can no
longer auto-inject a SKILL.md. 'odek skill promote' now falls back to
the project dir so these skills can be reviewed and promoted.
4. Taint coverage: web_search, vision, and delegate_tasks now taint the
session (like browser/http_batch), so skills learned from such
sessions are declined by auto-save. shell is deliberately excluded
(primary work tool - tainting it would taint nearly every session).
Auto-load skill context and skill_load tool output are now wrapped
with the nonce'd untrusted-content boundary.
5. The 'skills' guard scan scope now defaults to enabled (config +
docker configs + docs), adding the PiGuard semantic second opinion on
top of the local floor at save and load time.
Tests: 12+ new/updated tests across skills, memory, guard, config, and
CLI packages; full suite, -race on skills, and repo-wide golangci-lint
all clean.
-**Untrusted-content wrapper** (`cmd/odek/untrusted.go`) — every tool whose output sources from outside the trust boundary (`browser`, `read_file`, `shell`, `search_files`, `multi_grep`, `transcribe`, `head_tail`, `diff`, `tr`, `sort`, `json_query`, `batch_patch`, `glob`, `file_info`, `tree`, `base64` file mode, `session_search`, `@-resources`, `--ctx` files, any MCP tool) wraps results in `<untrusted_content_<nonce> source="...">…</untrusted_content_<nonce>>`. Browser page title and interactive-element text are wrapped in addition to the main content. Per-call nonce defeats wrapper-escape via literal close tag.
111
111
-**Audit log** (`cmd/odek/audit.go` + `internal/session/audit.go`) — every `wrapUntrusted` call records source + content-hash + turn into `<sessions>/audit/<id>.json`. After each turn a divergence heuristic flags `suspicious_divergence=true` when the agent ingested untrusted content AND its actions or final response reference resources that either did not appear in the user's message or were introduced by the untrusted content itself (closing response-only exfiltration and reused-resource injection bypasses). Inspect with `odek audit <session-id>` / `odek audit --list`.
112
112
-**Memory taint** (`internal/memory/provenance.go`) — `EpisodeProvenance` tracks Untrusted/Sources/UserApproved. Tainted episodes are stored but `Search()` filters them out, so a one-shot injection cannot persist via the episode pipeline. User must explicitly promote.
113
-
-**Skill provenance gate** (`internal/skills/loader.go` + `cache.go` + `tools.go`) — `Skill.Provenance{Untrusted, Sources, NeedsReview}`. NeedsReview skills pin to Lazy regardless of `auto_load`. The auto-save path declines tainted suggestions by default. Agent-created skills via `skill_save` and patched skills via `skill_patch` are forced to `Untrusted` + `NeedsReview`, and `skill_patch` refuses edits that touch the YAML frontmatter, blocking an injected agent from flipping `auto_load` or clearing `needs_review`. `odek skill promote <name> --force` clears the flag after explicit user review.
113
+
- **Skill provenance gate** (`internal/skills/loader.go` + `cache.go` + `tools.go`) — `Skill.Provenance{Untrusted, Sources, NeedsReview}`. NeedsReview skills pin to Lazy regardless of `auto_load` and are excluded from lazy trigger matching (the matchers in `reloadLocked` are built only from promotable skills), so a flagged skill cannot be injected into context on a keyword match. Skills scanned from the project-local `./.odek/skills/` directory are distrusted like `./odek.json`: they are forced to `NeedsReview` with `"project"` in `Sources` until promoted. The auto-save path declines tainted suggestions by default. Agent-created skills via `skill_save` and patched skills via `skill_patch` are forced to `Untrusted` + `NeedsReview`, and `skill_patch` refuses edits that touch the YAML frontmatter, blocking an injected agent from flipping `auto_load` or clearing `needs_review`. All skill-body scans (load, save, patch, suggestion) go through `guard.ScanContentWithScope`, so the fast local rule scan runs even when the `skills` guard scope or the guard itself is disabled; the sidecar second opinion only runs when the scope is enabled (default: on). Taint derives from `memory.ToolCallTaints`, whose always-external set covers `browser` / `http_batch` / `transcribe` / `session_search` / `web_search` / `vision` / `delegate_tasks` (`shell` is deliberately excluded as the primary work tool). `odek skill promote <name> --force` clears the flag after explicit user review.
114
114
-**Sub-agent damage cap** (`cmd/odek/subagent.go::applySubagentTrust`) — `delegate_tasks` carries `trust_level` + `max_risk`. The mutated `DangerousConfig` is passed into the sub-agent's `odek.Config` so the engine enforces the cap. Untrusted ⇒ NonInteractive=deny, Destructive/CodeExec/Install/SystemWrite/NetworkEgress/Unknown/Blocked all forced to Deny. `max_risk` ⇒ everything above cap forced to Deny. MCP tools are not loaded into untrusted sub-agents, because the MCP `ToolAdapter` does not perform its own danger check and would otherwise bypass the cap.
115
115
-**FD-based API key handoff** (`cmd/odek/subagent_key.go`) — parent writes key to a 0600 tempfile, immediately `unlink()`s, passes the FD via `cmd.ExtraFiles`. Sub-agent reads from `$ODEK_API_KEY_FD` and closes. Key never in `/proc/<pid>/environ`.
116
116
-**Approver friction** (`internal/danger/approver.go`, `cmd/odek/wsapprover.go`, `cmd/odek/shell.go`, `cmd/odek/perf_tools.go`) — both TTYApprover and WSApprover engage friction mode after 3 approvals of the same class in 60s: require typing literal `approve`, 1.5s pause. Trust-class shortcut disabled for `destructive` + `blocked` regardless. CLI `shell`/`parallel_shell` reuse a single TTYApprover instance per process so the counter and trust cache persist across prompts.
-**Skill learn-loop provenance propagation** (`internal/skills/learnloop.go`) — conversation-extracted suggestions and LLM-enhanced suggestions both retain the session's `SkillProvenance`, so tainted sessions cannot produce clean-looking auto-saved skills.
195
195
-**Audit ingest recording for @-refs, `--ctx`, and Web-UI attachments** (`cmd/odek/refs.go`, `cmd/odek/serve.go`, `cmd/odek/audit.go`) — the per-session ingest recorder is attached before `@`-reference/`--ctx`/attachment resolution, `recordTurnAudit` scans `user` messages for untrusted wrappers in addition to `tool` messages, and the divergence heuristic compares agent actions against the original pre-enrichment prompt so attacker-injected resources are not treated as user-mentioned.
196
196
-**Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop. `X-Forwarded-For` / `X-Real-Ip` headers are only honored when the direct remote address is in the configured `trusted_proxies` list, so spoofed headers cannot bypass the limiters.
197
-
-**Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
197
+
-**Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context (both auto-load and lazy trigger-matched), the `skill_load` tool output, and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
198
198
-**`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.
199
199
-**Web UI attachment wrapping** (`cmd/odek/serve.go` + `cmd/odek/ui/app.js`) — files attached through the browser are sent separately from the user's text and wrapped with the nonce'd `<untrusted_content_*>` boundary (`source="attachment:<filename>"`) before injection into the prompt.
200
200
-**Episode index session ID validation** (`internal/memory/episode_index.go` + `internal/session/session.go`) — `readAllSummaries` treats `index.json` as untrusted input and validates every `session_id` with `session.ValidateSessionID` before building the `filepath.Join(dir, sessionID+".md")` path. Invalid / traversal / separator-containing IDs are skipped with a warning, preventing a tampered episode index from pulling arbitrary files (e.g. `~/.odek/config.json`, `IDENTITY.md`) into the embedding space.
Copy file name to clipboardExpand all lines: docs/CONFIG.md
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -176,7 +176,7 @@ The guard is **off by default** in the sense that no sidecar is needed; the loca
176
176
"memory": true,
177
177
"system_prompt": true,
178
178
"mcp_descriptions": true,
179
-
"skills": false,
179
+
"skills": true,
180
180
"tool_outputs": false,
181
181
"telegram": false
182
182
}
@@ -203,11 +203,11 @@ The guard is **off by default** in the sense that no sidecar is needed; the loca
203
203
|`memory`|`true`|`memory` add/replace/consolidate, legacy facts, auto-extracted facts, session buffer, and Extended Memory atom extraction/addition/recall/user-model inference |
204
204
|`system_prompt`|`true`|`~/.odek/IDENTITY.md`, explicit `--system` / `ODEK_SYSTEM`, and project-level `AGENTS.md`|
205
205
|`mcp_descriptions`|`true`| MCP server tool descriptions supplied via `tools/list`|
206
-
|`skills`|`false`| Skill bodies loaded at startup; skill save/patch suggestions |
206
+
|`skills`|`true`| Skill bodies loaded at startup; skill save/patch suggestions |
207
207
|`tool_outputs`|`false`| External tool outputs wrapped as `<untrusted_content_*>` (warning-only scan) |
208
208
|`telegram`|`false`| Telegram photo captions and voice transcripts before injection |
209
209
210
-
When a scope is not explicitly set, the core surfaces (`memory`, `system_prompt`, `mcp_descriptions`) default to `true`; the optional expansion surfaces default to `false`.
210
+
When a scope is not explicitly set, the core surfaces (`memory`, `system_prompt`, `mcp_descriptions`, `skills`) default to `true`; the optional expansion surfaces default to `false`. Regardless of scope, the fast local rule scan always runs on every guarded surface — the scope only toggles the sidecar second opinion.
0 commit comments