whitebox follow up: better wiki

This commit is contained in:
bearsyankees
2026-03-31 16:44:48 -04:00
parent c0243367a8
commit 4f10ae40d7
11 changed files with 352 additions and 51 deletions

View File

@@ -116,8 +116,8 @@ WHITE-BOX TESTING (code provided):
- Static coverage target per repository: run one `semgrep` pass, one secrets pass (`gitleaks` and/or `trufflehog`), one `trivy fs` pass, and one AST-structural pass (`sg` and/or Tree-sitter); if any are skipped, record why in the shared wiki - Static coverage target per repository: run one `semgrep` pass, one secrets pass (`gitleaks` and/or `trufflehog`), one `trivy fs` pass, and one AST-structural pass (`sg` and/or Tree-sitter); if any are skipped, record why in the shared wiki
- Keep AST artifacts bounded and high-signal: scope to relevant paths/hypotheses, avoid whole-repo generic function dumps - Keep AST artifacts bounded and high-signal: scope to relevant paths/hypotheses, avoid whole-repo generic function dumps
- AST target selection rule: build `sg-targets.txt` from `semgrep.json` scope first (`paths.scanned`, fallback to unique `results[].path`), then run `xargs ... sg run` against that file list. Only use path-heuristic fallback if semgrep scope is unavailable, and log fallback reason in the wiki. - AST target selection rule: build `sg-targets.txt` from `semgrep.json` scope first (`paths.scanned`, fallback to unique `results[].path`), then run `xargs ... sg run` against that file list. Only use path-heuristic fallback if semgrep scope is unavailable, and log fallback reason in the wiki.
- Shared memory: Use notes as shared working memory; discover wiki notes with `list_notes`, then read the selected one via `get_note(note_id=...)` before analysis - Shared memory: Use notes as shared working memory; discover wiki notes with `list_notes`, read `wiki:overview` first when available, then read `wiki:security` via `get_note(note_id=...)` before analysis
- Before `agent_finish`/`finish_scan`, update the shared repo wiki with scanner summaries, key routes/sinks, and dynamic follow-up plan - Before `agent_finish`/`finish_scan`, update `wiki:security` with scanner summaries, key routes/sinks, and dynamic follow-up plan
- Dynamic: Run the application and test live to validate exploitability - Dynamic: Run the application and test live to validate exploitability
- NEVER rely solely on static code analysis when dynamic validation is possible - NEVER rely solely on static code analysis when dynamic validation is possible
- Begin with fast source triage and dynamic run preparation in parallel; use static findings to prioritize live testing. - Begin with fast source triage and dynamic run preparation in parallel; use static findings to prioritize live testing.

View File

@@ -44,14 +44,16 @@ Coverage target per repository:
## Wiki Note Requirement (Source Map) ## Wiki Note Requirement (Source Map)
When source is present, maintain one wiki note per repository and keep it current. When source is present, maintain two stable wiki notes per repository and keep them current:
- `wiki:overview` for architecture/source-map context
- `wiki:security` for scanner and validation deltas
Operational rules: Operational rules:
- At task start, call `list_notes` with `category=wiki`, then read the selected wiki with `get_note(note_id=...)`. - At task start, call `list_notes` with `category=wiki`; read `wiki:overview` first, then `wiki:security` via `get_note(note_id=...)`.
- If no repo wiki exists, create one with `create_note` and `category=wiki`. - If wiki notes are missing, create them with `create_note`, `category=wiki`, and tags including `wiki:overview` or `wiki:security`.
- Update the same wiki via `update_note`; avoid creating duplicate wiki notes for the same repo. - Update existing notes via `update_note`; avoid creating duplicates.
- Child agents should read wiki notes first via `get_note`, then extend with new evidence from their scope. - Child agents should read both notes first, then extend with new evidence from their scope.
- Before calling `agent_finish`, each source-focused child agent should append a short delta update to the shared repo wiki (scanner outputs, route/sink map deltas, dynamic follow-ups). - Before calling `agent_finish`, each source-focused child agent should append a short delta update to `wiki:security` (scanner outputs, route/sink map deltas, dynamic follow-ups).
Recommended sections: Recommended sections:
- Architecture overview - Architecture overview

View File

@@ -19,12 +19,12 @@ Before scanning, check shared wiki memory:
```text ```text
1) list_notes(category="wiki") 1) list_notes(category="wiki")
2) get_note(note_id=...) for the selected repo wiki before analysis 2) get_note(note_id=...) for `wiki:overview` first, then `wiki:security`
3) Reuse matching repo wiki note if present 3) Reuse matching repo wiki notes if present
4) create_note(category="wiki") only if missing 4) create_note(category="wiki") only if missing (with tags `wiki:overview` / `wiki:security`)
``` ```
After every major source-analysis batch, update the same repo wiki note with `update_note` so other agents can reuse your latest map. After every major source-analysis batch, update `wiki:security` with `update_note` so other agents can reuse your latest map.
## Baseline Coverage Bundle (Recommended) ## Baseline Coverage Bundle (Recommended)
@@ -74,7 +74,7 @@ trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \
--format json --output "$ART/trivy-fs.json" . || true --format json --output "$ART/trivy-fs.json" . || true
``` ```
If one tool is skipped or fails, record that in the shared wiki note along with the reason. If one tool is skipped or fails, record that in `wiki:security` along with the reason.
## Semgrep First Pass ## Semgrep First Pass
@@ -143,7 +143,7 @@ trivy fs --scanners vuln,misconfig --timeout 30m --offline-scan \
## Wiki Update Template ## Wiki Update Template
Keep one wiki note per repository and update these sections: Keep `wiki:overview` and `wiki:security` per repository. Update these sections in `wiki:security`:
```text ```text
## Architecture ## Architecture
@@ -164,4 +164,4 @@ Before `agent_finish`, make one final `update_note` call to capture:
- Do not treat scanner output as final truth. - Do not treat scanner output as final truth.
- Do not spend full cycles on low-signal pattern matches. - Do not spend full cycles on low-signal pattern matches.
- Do not report source-only findings without validation evidence. - Do not report source-only findings without validation evidence.
- Do not create multiple wiki notes for the same repository when one already exists. - Do not create duplicate `wiki:overview` or `wiki:security` notes for the same repository.

View File

@@ -15,7 +15,7 @@ Thorough understanding before exploitation. Test every parameter, every endpoint
**Whitebox (source available)** **Whitebox (source available)**
- Map every file, module, and code path in the repository - Map every file, module, and code path in the repository
- Load and maintain shared `wiki` notes from the start (`list_notes(category="wiki")` then `get_note(note_id=...)`), then continuously update one repo note - Load and maintain shared `wiki` notes from the start (`list_notes(category="wiki")`, then `get_note(note_id=...)` for `wiki:overview` and `wiki:security`), then continuously update `wiki:security`
- Start with broad source-aware triage (`semgrep`, `ast-grep`, `gitleaks`, `trufflehog`, `trivy fs`) and use outputs to drive deep review - Start with broad source-aware triage (`semgrep`, `ast-grep`, `gitleaks`, `trufflehog`, `trivy fs`) and use outputs to drive deep review
- Execute at least one structural AST pass (`sg` and/or Tree-sitter) per repository and store artifacts for reuse - Execute at least one structural AST pass (`sg` and/or Tree-sitter) per repository and store artifacts for reuse
- Keep AST artifacts bounded and query-driven (target relevant paths/sinks first; avoid whole-repo generic function dumps) - Keep AST artifacts bounded and query-driven (target relevant paths/sinks first; avoid whole-repo generic function dumps)
@@ -31,7 +31,7 @@ Thorough understanding before exploitation. Test every parameter, every endpoint
- Review file handling: upload, download, processing - Review file handling: upload, download, processing
- Understand the deployment model and infrastructure assumptions - Understand the deployment model and infrastructure assumptions
- Check all dependency versions and repository risks against CVE/misconfiguration data - Check all dependency versions and repository risks against CVE/misconfiguration data
- Before final completion, update the shared repo wiki with scanner summary + dynamic follow-ups - Before final completion, update `wiki:security` with scanner summary + dynamic follow-ups
**Blackbox (no source)** **Blackbox (no source)**
- Exhaustive subdomain enumeration with multiple sources and tools - Exhaustive subdomain enumeration with multiple sources and tools

View File

@@ -15,7 +15,7 @@ Optimize for fast feedback on critical security issues. Skip exhaustive enumerat
**Whitebox (source available)** **Whitebox (source available)**
- Focus on recent changes: git diffs, new commits, modified files—these are most likely to contain fresh bugs - Focus on recent changes: git diffs, new commits, modified files—these are most likely to contain fresh bugs
- Read existing `wiki` notes first (`list_notes(category="wiki")` then `get_note(note_id=...)`) to avoid remapping from scratch - Read existing `wiki` notes first (`list_notes(category="wiki")`, then `get_note(note_id=...)` for `wiki:overview` and `wiki:security`) to avoid remapping from scratch
- Run a fast static triage on changed files first (`semgrep`, then targeted `sg` queries) - Run a fast static triage on changed files first (`semgrep`, then targeted `sg` queries)
- Run at least one lightweight AST pass (`sg` or Tree-sitter) so structural mapping is not skipped - Run at least one lightweight AST pass (`sg` or Tree-sitter) so structural mapping is not skipped
- Keep AST commands tightly scoped to changed or high-risk paths; avoid broad repository-wide pattern dumps - Keep AST commands tightly scoped to changed or high-risk paths; avoid broad repository-wide pattern dumps
@@ -23,7 +23,7 @@ Optimize for fast feedback on critical security issues. Skip exhaustive enumerat
- Identify security-sensitive patterns in changed code: auth checks, input handling, database queries, file operations - Identify security-sensitive patterns in changed code: auth checks, input handling, database queries, file operations
- Trace user input through modified code paths - Trace user input through modified code paths
- Check if security controls were modified or bypassed - Check if security controls were modified or bypassed
- Before completion, update the shared repo wiki with what changed and what needs dynamic follow-up - Before completion, update `wiki:security` with what changed and what needs dynamic follow-up
**Blackbox (no source)** **Blackbox (no source)**
- Map authentication and critical user flows - Map authentication and critical user flows

View File

@@ -15,7 +15,7 @@ Systematic testing across the full attack surface. Understand the application be
**Whitebox (source available)** **Whitebox (source available)**
- Map codebase structure: modules, entry points, routing - Map codebase structure: modules, entry points, routing
- Start by loading existing `wiki` notes (`list_notes(category="wiki")` then `get_note(note_id=...)`) and update one shared repo note as mapping evolves - Start by loading existing `wiki` notes (`list_notes(category="wiki")`, then `get_note(note_id=...)` for `wiki:overview` and `wiki:security`) and update `wiki:security` as mapping evolves
- Run `semgrep` first-pass triage to prioritize risky flows before deep manual review - Run `semgrep` first-pass triage to prioritize risky flows before deep manual review
- Run at least one AST-structural mapping pass (`sg` and/or Tree-sitter), then use outputs for route, sink, and trust-boundary mapping - Run at least one AST-structural mapping pass (`sg` and/or Tree-sitter), then use outputs for route, sink, and trust-boundary mapping
- Keep AST output bounded to relevant paths and hypotheses; avoid whole-repo generic function dumps - Keep AST output bounded to relevant paths and hypotheses; avoid whole-repo generic function dumps
@@ -25,7 +25,7 @@ Systematic testing across the full attack surface. Understand the application be
- Analyze database interactions and ORM usage - Analyze database interactions and ORM usage
- Check dependencies and repo risks with `trivy fs`, `gitleaks`, and `trufflehog` - Check dependencies and repo risks with `trivy fs`, `gitleaks`, and `trufflehog`
- Understand the data model and sensitive data locations - Understand the data model and sensitive data locations
- Before completion, update the shared repo wiki with source findings summary and dynamic validation next steps - Before completion, update `wiki:security` with source findings summary and dynamic validation next steps
**Blackbox (no source)** **Blackbox (no source)**
- Crawl application thoroughly, interact with every feature - Crawl application thoroughly, interact with every feature

View File

@@ -44,7 +44,32 @@ def _extract_repo_tags(agent_state: Any | None) -> set[str]:
return repo_tags return repo_tags
def _load_primary_wiki_note(agent_state: Any | None = None) -> dict[str, Any] | None: def _extract_wiki_kind(note: dict[str, Any]) -> str:
note_kind = str(note.get("wiki_kind") or "").strip().lower()
if note_kind in {"overview", "security", "general"}:
return note_kind
note_tags = note.get("tags") or []
if isinstance(note_tags, list):
normalized_tags = {str(tag).strip().lower() for tag in note_tags if str(tag).strip()}
if "wiki:overview" in normalized_tags:
return "overview"
if "wiki:security" in normalized_tags:
return "security"
title = str(note.get("title") or "").lower()
if "overview" in title or "architecture" in title:
return "overview"
if "security" in title or "vuln" in title or "finding" in title:
return "security"
return "general"
def _load_primary_wiki_note(
agent_state: Any | None = None,
preferred_kind: str | None = None,
allow_kind_fallback: bool = True,
) -> dict[str, Any] | None:
try: try:
from strix.tools.notes.notes_actions import get_note, list_notes from strix.tools.notes.notes_actions import get_note, list_notes
@@ -56,19 +81,32 @@ def _load_primary_wiki_note(agent_state: Any | None = None) -> dict[str, Any] |
if not notes: if not notes:
return None return None
candidate_notes = notes
selected_note_id = None selected_note_id = None
repo_tags = _extract_repo_tags(agent_state) repo_tags = _extract_repo_tags(agent_state)
if repo_tags: if repo_tags:
tagged_notes = []
for note in notes: for note in notes:
note_tags = note.get("tags") or [] note_tags = note.get("tags") or []
if not isinstance(note_tags, list): if not isinstance(note_tags, list):
continue continue
normalized_note_tags = {str(tag).strip().lower() for tag in note_tags if str(tag).strip()} normalized_note_tags = {str(tag).strip().lower() for tag in note_tags if str(tag).strip()}
if normalized_note_tags.intersection(repo_tags): if normalized_note_tags.intersection(repo_tags):
tagged_notes.append(note)
if tagged_notes:
candidate_notes = tagged_notes
normalized_kind = (preferred_kind or "").strip().lower()
if normalized_kind in {"overview", "security", "general"}:
for note in candidate_notes:
if _extract_wiki_kind(note) == normalized_kind:
selected_note_id = note.get("note_id") selected_note_id = note.get("note_id")
break break
note_id = selected_note_id or notes[0].get("note_id") if not selected_note_id and (not normalized_kind or allow_kind_fallback):
selected_note_id = candidate_notes[0].get("note_id")
note_id = selected_note_id
if not isinstance(note_id, str) or not note_id: if not isinstance(note_id, str) or not note_id:
return None return None
@@ -90,22 +128,40 @@ def _inject_wiki_context_for_whitebox(agent_state: Any) -> None:
if not _is_whitebox_agent(agent_state.agent_id): if not _is_whitebox_agent(agent_state.agent_id):
return return
wiki_note = _load_primary_wiki_note(agent_state) overview_note = _load_primary_wiki_note(
if not wiki_note: agent_state,
return preferred_kind="overview",
allow_kind_fallback=False,
)
security_note = _load_primary_wiki_note(
agent_state,
preferred_kind="security",
allow_kind_fallback=True,
)
title = str(wiki_note.get("title") or "repo wiki") notes_to_embed: list[tuple[str, dict[str, Any]]] = []
content = str(wiki_note.get("content") or "").strip() if isinstance(overview_note, dict):
if not content: notes_to_embed.append(("overview", overview_note))
return
if isinstance(security_note, dict):
overview_note_id = str(overview_note.get("note_id")) if isinstance(overview_note, dict) else ""
security_note_id = str(security_note.get("note_id"))
if not overview_note_id or overview_note_id != security_note_id:
notes_to_embed.append(("security", security_note))
max_chars = 4000 max_chars = 4000
for wiki_kind, note in notes_to_embed:
title = str(note.get("title") or "repo wiki")
content = str(note.get("content") or "").strip()
if not content:
continue
truncated_content = content[:max_chars] truncated_content = content[:max_chars]
suffix = "\n\n[truncated for context size]" if len(content) > max_chars else "" suffix = "\n\n[truncated for context size]" if len(content) > max_chars else ""
agent_state.add_message( agent_state.add_message(
"user", "user",
( (
f"<shared_repo_wiki title=\"{title}\">\n" f"<shared_repo_wiki type=\"{wiki_kind}\" title=\"{title}\">\n"
f"{truncated_content}{suffix}\n" f"{truncated_content}{suffix}\n"
"</shared_repo_wiki>" "</shared_repo_wiki>"
), ),
@@ -125,7 +181,11 @@ def _append_wiki_update_on_finish(
try: try:
from strix.tools.notes.notes_actions import append_note_content from strix.tools.notes.notes_actions import append_note_content
note = _load_primary_wiki_note(agent_state) note = _load_primary_wiki_note(
agent_state,
preferred_kind="security",
allow_kind_fallback=True,
)
if not note: if not note:
return return
@@ -179,10 +239,10 @@ def _run_agent_in_thread(
wiki_memory_instruction = "" wiki_memory_instruction = ""
if getattr(getattr(agent, "llm_config", None), "is_whitebox", False): if getattr(getattr(agent, "llm_config", None), "is_whitebox", False):
wiki_memory_instruction = ( wiki_memory_instruction = (
'\n - White-box memory (recommended): call list_notes(category="wiki") and then ' '\n - White-box memory (recommended): call list_notes(category="wiki"), read '
"get_note(note_id=...) before substantive work (including terminal scans)" "wiki:overview first when available, then wiki:security via get_note(note_id=...) before substantive work (including terminal scans)"
"\n - Reuse one repo wiki note where possible and avoid duplicates" "\n - Prefer two stable wiki notes per repo: one tagged wiki:overview and one tagged wiki:security; avoid duplicates"
"\n - Before agent_finish, call list_notes(category=\"wiki\") + get_note(note_id=...) again, then append a short scope delta via update_note (new routes/sinks, scanner results, dynamic follow-ups)" "\n - Before agent_finish, call list_notes(category=\"wiki\") + get_note(note_id=...) again, then append a short scope delta via update_note to wiki:security (new routes/sinks, scanner results, dynamic follow-ups)"
"\n - If terminal output contains `command not found` or shell parse errors, correct and rerun before using the result" "\n - If terminal output contains `command not found` or shell parse errors, correct and rerun before using the result"
"\n - Use ASCII-only shell commands; if a command includes unexpected non-ASCII characters, rerun with a clean ASCII command" "\n - Use ASCII-only shell commands; if a command includes unexpected non-ASCII characters, rerun with a clean ASCII command"
"\n - Keep AST artifacts bounded: target relevant paths and avoid whole-repo generic function dumps" "\n - Keep AST artifacts bounded: target relevant paths and avoid whole-repo generic function dumps"
@@ -382,10 +442,10 @@ def create_agent(
"keep artifacts bounded and skip forced AST steps for purely dynamic validation tasks.\n" "keep artifacts bounded and skip forced AST steps for purely dynamic validation tasks.\n"
"- Keep AST output bounded: scope to relevant paths/files, avoid whole-repo " "- Keep AST output bounded: scope to relevant paths/files, avoid whole-repo "
"generic function patterns, and cap artifact size.\n" "generic function patterns, and cap artifact size.\n"
'- Use shared wiki memory by calling list_notes(category="wiki") then ' '- Use shared wiki memory by calling list_notes(category="wiki"), reading wiki:overview first '
"get_note(note_id=...).\n" "then wiki:security via get_note(note_id=...).\n"
'- Before agent_finish, call list_notes(category="wiki") + get_note(note_id=...) ' '- Before agent_finish, call list_notes(category="wiki") + get_note(note_id=...) '
"again, reuse one repo wiki, and call update_note.\n" "again, and append updates to wiki:security.\n"
"- If terminal output contains `command not found` or shell parse errors, " "- If terminal output contains `command not found` or shell parse errors, "
"correct and rerun before using the result." "correct and rerun before using the result."
) )

View File

@@ -15,6 +15,28 @@ _loaded_notes_run_dir: str | None = None
_DEFAULT_CONTENT_PREVIEW_CHARS = 280 _DEFAULT_CONTENT_PREVIEW_CHARS = 280
def _note_tag_set(note: dict[str, Any]) -> set[str]:
tags = note.get("tags", [])
if not isinstance(tags, list):
return set()
return {str(tag).strip().lower() for tag in tags if str(tag).strip()}
def _infer_wiki_kind(note: dict[str, Any]) -> str:
tag_set = _note_tag_set(note)
if "wiki:overview" in tag_set:
return "overview"
if "wiki:security" in tag_set:
return "security"
title = str(note.get("title", "")).lower()
if "overview" in title or "architecture" in title:
return "overview"
if "security" in title or "vuln" in title or "finding" in title:
return "security"
return "general"
def _get_run_dir() -> Path | None: def _get_run_dir() -> Path | None:
try: try:
from strix.telemetry.tracer import get_global_tracer from strix.telemetry.tracer import get_global_tracer
@@ -107,6 +129,7 @@ def _ensure_notes_loaded() -> None:
for note_id, note in _notes_storage.items(): for note_id, note in _notes_storage.items():
if note.get("category") == "wiki": if note.get("category") == "wiki":
_persist_wiki_note(note_id, note) _persist_wiki_note(note_id, note)
_persist_wiki_index()
except OSError: except OSError:
pass pass
@@ -133,6 +156,13 @@ def _get_wiki_directory() -> Path | None:
return wiki_dir return wiki_dir
def _get_wiki_index_path() -> Path | None:
wiki_dir = _get_wiki_directory()
if not wiki_dir:
return None
return wiki_dir / "index.json"
def _get_wiki_note_path(note_id: str, note: dict[str, Any]) -> Path | None: def _get_wiki_note_path(note_id: str, note: dict[str, Any]) -> Path | None:
wiki_dir = _get_wiki_directory() wiki_dir = _get_wiki_directory()
if not wiki_dir: if not wiki_dir:
@@ -167,6 +197,34 @@ def _persist_wiki_note(note_id: str, note: dict[str, Any]) -> None:
wiki_path.write_text(content, encoding="utf-8") wiki_path.write_text(content, encoding="utf-8")
def _persist_wiki_index() -> None:
index_path = _get_wiki_index_path()
if not index_path:
return
notes: list[dict[str, Any]] = []
for note_id, note in _notes_storage.items():
if note.get("category") != "wiki":
continue
wiki_path = _get_wiki_note_path(note_id, note)
notes.append(
{
"note_id": note_id,
"title": str(note.get("title", "")),
"wiki_kind": _infer_wiki_kind(note),
"tags": note.get("tags", []),
"created_at": note.get("created_at", ""),
"updated_at": note.get("updated_at", ""),
"wiki_filename": note.get("wiki_filename", ""),
"wiki_path": wiki_path.name if wiki_path else "",
}
)
notes.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
payload = {"generated_at": datetime.now(UTC).isoformat(), "notes": notes}
index_path.write_text(f"{json.dumps(payload, ensure_ascii=True, indent=2)}\n", encoding="utf-8")
def _remove_wiki_note(note_id: str, note: dict[str, Any]) -> None: def _remove_wiki_note(note_id: str, note: dict[str, Any]) -> None:
wiki_path = _get_wiki_note_path(note_id, note) wiki_path = _get_wiki_note_path(note_id, note)
if not wiki_path: if not wiki_path:
@@ -226,6 +284,9 @@ def _to_note_listing_entry(
if isinstance(wiki_filename, str) and wiki_filename: if isinstance(wiki_filename, str) and wiki_filename:
entry["wiki_filename"] = wiki_filename entry["wiki_filename"] = wiki_filename
if note.get("category") == "wiki":
entry["wiki_kind"] = _infer_wiki_kind(note)
content = str(note.get("content", "")) content = str(note.get("content", ""))
if include_content: if include_content:
entry["content"] = content entry["content"] = content
@@ -290,6 +351,7 @@ def create_note( # noqa: PLR0911
_append_note_event("create", note_id, note) _append_note_event("create", note_id, note)
if category == "wiki": if category == "wiki":
_persist_wiki_note(note_id, note) _persist_wiki_note(note_id, note)
_persist_wiki_index()
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return {"success": False, "error": f"Failed to create note: {e}", "note_id": None} return {"success": False, "error": f"Failed to create note: {e}", "note_id": None}
@@ -356,6 +418,8 @@ def get_note(note_id: str) -> dict[str, Any]:
note_with_id = note.copy() note_with_id = note.copy()
note_with_id["note_id"] = note_id note_with_id["note_id"] = note_id
if note.get("category") == "wiki":
note_with_id["wiki_kind"] = _infer_wiki_kind(note)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return { return {
@@ -420,6 +484,7 @@ def update_note(
_append_note_event("update", note_id, note) _append_note_event("update", note_id, note)
if note.get("category") == "wiki": if note.get("category") == "wiki":
_persist_wiki_note(note_id, note) _persist_wiki_note(note_id, note)
_persist_wiki_index()
return { return {
"success": True, "success": True,
@@ -443,10 +508,13 @@ def delete_note(note_id: str) -> dict[str, Any]:
note = _notes_storage[note_id] note = _notes_storage[note_id]
note_title = note["title"] note_title = note["title"]
if note.get("category") == "wiki": is_wiki = note.get("category") == "wiki"
if is_wiki:
_remove_wiki_note(note_id, note) _remove_wiki_note(note_id, note)
del _notes_storage[note_id] del _notes_storage[note_id]
_append_note_event("delete", note_id) _append_note_event("delete", note_id)
if is_wiki:
_persist_wiki_index()
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
return {"success": False, "error": f"Failed to delete note: {e}"} return {"success": False, "error": f"Failed to delete note: {e}"}

View File

@@ -4,6 +4,7 @@
<details>Use this tool for documenting discoveries, observations, methodology notes, and questions. <details>Use this tool for documenting discoveries, observations, methodology notes, and questions.
This is your personal and shared run memory for recording information you want to remember or reference later. This is your personal and shared run memory for recording information you want to remember or reference later.
Use category "wiki" for repository source maps shared across agents in the same run. Use category "wiki" for repository source maps shared across agents in the same run.
For Codewiki patterns, prefer wiki tags `wiki:overview` and `wiki:security` for stable note roles.
For tracking actionable tasks, use the todo tool instead.</details> For tracking actionable tasks, use the todo tool instead.</details>
<parameters> <parameters>
<parameter name="title" type="string" required="true"> <parameter name="title" type="string" required="true">
@@ -109,7 +110,7 @@ The /api/internal/* endpoints are high priority as they appear to lack authentic
</parameter> </parameter>
</parameters> </parameters>
<returns type="Dict[str, Any]"> <returns type="Dict[str, Any]">
<description>Response containing: - notes: List of matching notes (metadata + optional content/content_preview) - total_count: Total number of notes found</description> <description>Response containing: - notes: List of matching notes (metadata + optional content/content_preview; wiki entries include wiki_kind) - total_count: Total number of notes found</description>
</returns> </returns>
<examples> <examples>
# List all findings # List all findings

View File

@@ -296,3 +296,120 @@ def test_load_primary_wiki_note_prefers_repo_tag_match(monkeypatch) -> None:
assert note is not None assert note is not None
assert note["note_id"] == "wiki-target" assert note["note_id"] == "wiki-target"
assert selected_note_ids == ["wiki-target"] assert selected_note_ids == ["wiki-target"]
def test_load_primary_wiki_note_prefers_requested_wiki_kind(monkeypatch) -> None:
selected_note_ids: list[str] = []
def fake_list_notes(category=None):
assert category == "wiki"
return {
"success": True,
"notes": [
{"note_id": "wiki-security", "tags": ["repo:appsmith", "wiki:security"]},
{"note_id": "wiki-overview", "tags": ["repo:appsmith", "wiki:overview"]},
],
"total_count": 2,
}
def fake_get_note(note_id: str):
selected_note_ids.append(note_id)
return {
"success": True,
"note": {
"note_id": note_id,
"title": "Repo Wiki",
"content": "content",
},
}
monkeypatch.setattr("strix.tools.notes.notes_actions.list_notes", fake_list_notes)
monkeypatch.setattr("strix.tools.notes.notes_actions.get_note", fake_get_note)
agent_state = SimpleNamespace(task="analyze /workspace/appsmith")
overview_note = agents_graph_actions._load_primary_wiki_note(
agent_state,
preferred_kind="overview",
allow_kind_fallback=False,
)
security_note = agents_graph_actions._load_primary_wiki_note(
agent_state,
preferred_kind="security",
allow_kind_fallback=True,
)
assert overview_note is not None
assert security_note is not None
assert overview_note["note_id"] == "wiki-overview"
assert security_note["note_id"] == "wiki-security"
assert selected_note_ids == ["wiki-overview", "wiki-security"]
def test_agent_finish_prefers_security_wiki_for_append(monkeypatch) -> None:
monkeypatch.setenv("STRIX_LLM", "openai/gpt-5")
agents_graph_actions._agent_graph["nodes"].clear()
agents_graph_actions._agent_graph["edges"].clear()
agents_graph_actions._agent_messages.clear()
agents_graph_actions._running_agents.clear()
agents_graph_actions._agent_instances.clear()
agents_graph_actions._agent_states.clear()
parent_id = "parent-sec"
child_id = "child-sec"
agents_graph_actions._agent_graph["nodes"][parent_id] = {
"name": "Parent",
"task": "parent task",
"status": "running",
"parent_id": None,
}
agents_graph_actions._agent_graph["nodes"][child_id] = {
"name": "Child",
"task": "child task",
"status": "running",
"parent_id": parent_id,
}
agents_graph_actions._agent_instances[child_id] = SimpleNamespace(
llm_config=LLMConfig(is_whitebox=True)
)
captured: dict[str, str] = {}
def fake_list_notes(category=None):
assert category == "wiki"
return {
"success": True,
"notes": [
{"note_id": "wiki-overview", "tags": ["repo:appsmith", "wiki:overview"]},
{"note_id": "wiki-security", "tags": ["repo:appsmith", "wiki:security"]},
],
"total_count": 2,
}
def fake_get_note(note_id: str):
return {
"success": True,
"note": {"note_id": note_id, "title": "Repo Wiki", "content": "Existing wiki content"},
}
def fake_append_note_content(note_id: str, delta: str):
captured["note_id"] = note_id
captured["delta"] = delta
return {"success": True, "note_id": note_id}
monkeypatch.setattr("strix.tools.notes.notes_actions.list_notes", fake_list_notes)
monkeypatch.setattr("strix.tools.notes.notes_actions.get_note", fake_get_note)
monkeypatch.setattr("strix.tools.notes.notes_actions.append_note_content", fake_append_note_content)
state = SimpleNamespace(agent_id=child_id, parent_id=parent_id, task="analyze /workspace/appsmith")
result = agents_graph_actions.agent_finish(
agent_state=state,
result_summary="Static triage completed",
findings=["Found candidate sink"],
success=True,
final_recommendations=["Validate with dynamic PoC"],
)
assert result["agent_completed"] is True
assert captured["note_id"] == "wiki-security"
assert "Static triage completed" in captured["delta"]

View File

@@ -124,7 +124,7 @@ def test_get_note_returns_full_note(tmp_path: Path, monkeypatch) -> None:
title="Repo wiki", title="Repo wiki",
content="entrypoints and sinks", content="entrypoints and sinks",
category="wiki", category="wiki",
tags=["repo:appsmith"], tags=["repo:appsmith", "wiki:security"],
) )
assert created["success"] is True assert created["success"] is True
note_id = created["note_id"] note_id = created["note_id"]
@@ -134,6 +134,7 @@ def test_get_note_returns_full_note(tmp_path: Path, monkeypatch) -> None:
assert result["success"] is True assert result["success"] is True
assert result["note"]["note_id"] == note_id assert result["note"]["note_id"] == note_id
assert result["note"]["content"] == "entrypoints and sinks" assert result["note"]["content"] == "entrypoints and sinks"
assert result["note"]["wiki_kind"] == "security"
finally: finally:
_reset_notes_state() _reset_notes_state()
set_global_tracer(previous_tracer) # type: ignore[arg-type] set_global_tracer(previous_tracer) # type: ignore[arg-type]
@@ -212,3 +213,55 @@ def test_list_and_get_note_handle_wiki_repersist_oserror_gracefully(
finally: finally:
_reset_notes_state() _reset_notes_state()
set_global_tracer(previous_tracer) # type: ignore[arg-type] set_global_tracer(previous_tracer) # type: ignore[arg-type]
def test_wiki_index_tracks_overview_and_security_notes(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
_reset_notes_state()
previous_tracer = get_global_tracer()
tracer = Tracer("wiki-index-run")
set_global_tracer(tracer)
try:
overview = notes_actions.create_note(
title="Repo overview wiki",
content="architecture and entrypoints",
category="wiki",
tags=["repo:demo", "wiki:overview"],
)
assert overview["success"] is True
overview_id = overview["note_id"]
assert isinstance(overview_id, str)
security = notes_actions.create_note(
title="Repo security wiki",
content="scanner summary and follow-ups",
category="wiki",
tags=["repo:demo", "wiki:security"],
)
assert security["success"] is True
security_id = security["note_id"]
assert isinstance(security_id, str)
wiki_index = tmp_path / "strix_runs" / "wiki-index-run" / "wiki" / "index.json"
assert wiki_index.exists() is True
index_data = wiki_index.read_text(encoding="utf-8")
assert '"wiki_kind": "overview"' in index_data
assert '"wiki_kind": "security"' in index_data
listed = notes_actions.list_notes(category="wiki")
assert listed["success"] is True
note_kinds = {note["note_id"]: note.get("wiki_kind") for note in listed["notes"]}
assert note_kinds[overview_id] == "overview"
assert note_kinds[security_id] == "security"
deleted = notes_actions.delete_note(note_id=overview_id)
assert deleted["success"] is True
index_after_delete = wiki_index.read_text(encoding="utf-8")
assert overview_id not in index_after_delete
assert security_id in index_after_delete
finally:
_reset_notes_state()
set_global_tracer(previous_tracer) # type: ignore[arg-type]