whitebox follow up: better wiki
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user