Esqueleto inicial: sabia-core + sabia-cli + UI local, OpenWolf

- sabia-core: descoberta de interfaces, controle de modo monitor/canal
  via iw (ponte até ter nl80211 nativo), captura crua 802.11 via
  AF_PACKET (sem libpcap), parsing de radiotap e quadros de
  gerenciamento (beacon/probe/EAPOL), agregação em tabela de APs/
  clientes, escrita de .pcap, trilha de auditoria encadeada por hash,
  auto-atualização via GitHub releases (curl, sem dependências de
  rede extras)
- sabia-cli: `sabia iface`, `sabia scan`, `sabia update`,
  `sabia audit-verify`, `sabia ui` (servidor local + navegador,
  protegido por token, mesma filosofia do Guará)
- OpenWolf inicializado para memória de projeto entre agentes
This commit is contained in:
Lucas committed 2026-10-23 08:22:04 -03:00
commit e989413018
76 files changed
+8386

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
---
description: Screenshot-based design review of the running app via openwolf designqc
argument-hint: [--url <url>] [--routes <routes>]
---
Arguments: $ARGUMENTS
Evaluate and improve the design/UI of this app:
1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above).
- The command auto-detects a running dev server, or starts one from package.json if needed.
- Use `--url <url>` only if auto-detection fails.
- Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom).
2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool.
3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns):
- Spacing and whitespace consistency
- Typography hierarchy and readability
- Color contrast and accessibility (WCAG)
- Visual hierarchy and focal points
- Component consistency
- Whether the design looks generic ("white-coded", no personality)
4. Provide specific, actionable feedback with fix suggestions.
5. If the user approves, implement the fixes directly in their code.
6. Re-run `openwolf designqc` to verify the improvement.
Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures.
+25
View File
@@ -0,0 +1,25 @@
Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS
Build it from the session's actual state, not from memory of the conversation alone:
1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items.
2. Run `git status --short` and `git log --oneline -8` to see what actually changed.
3. Skim the latest session block of `.wolf/memory.md` for the action log.
Then rewrite `.wolf/STATUS.md` with:
- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail.
- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make.
- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks).
- Bump the date.
Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one.
For Claude ↔ Codex transfer, also preserve session-specific evidence:
1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task.
2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent <agent> --session <id> --file <json>`. Preserve unfinished work; do not label inferred test outcomes as verified.
3. Run `openwolf handoff export --from <agent> --session <id> --to <destination> --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session.
4. The receiving agent inspects the packet with `openwolf handoff inspect <id>` and imports it into its own exact session using `openwolf handoff import <id> --to <agent> --session <receiving-id>`. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets.
5. Use `openwolf handoff recover --from <agent> --session <id>` if hooks were missed, and `openwolf handoff search "<error or symbol>"` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules.
+41
View File
@@ -0,0 +1,41 @@
---
description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles
argument-hint: [migrate [framework] | audit [target] | fix [target]]
---
Arguments: $ARGUMENTS
Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles
(anti-generic mandate)**, the framework knowledge base, and the migration prompts.
Use `.wolf/anatomy.md` to locate UI files instead of scanning.
Pick the mode from the arguments (default: `migrate` if a framework is named or the
user is choosing one; otherwise ask which mode they want):
## Mode: migrate [framework]
Framework selection and migration.
1. If no framework is named, ask the Decision Questions from the knowledge file
(stop early once the answer narrows to 1–2 options) and recommend one.
2. Use that framework's prompt from the knowledge file, adapted to this project's
real structure via `.wolf/anatomy.md`.
3. The Design Principles override anything generic in the prompt: no template
hero→features→CTA structures, no stock palettes — distinctive by default.
## Mode: audit [target]
Walk the target (default: the whole UI) and flag every match against the AI-tell
blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything,
emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for
every role, template SaaS structure, filler microcopy. Produce a findings table —
component, tell matched, severity, specific replacement direction — and end with the
3 changes that would most increase distinctiveness.
## Mode: fix [target]
Run the audit, then fix findings in severity order. Fixes must move toward, not merely
away: typography chosen with intent, a palette derived from the product's actual
brand/domain, asymmetry where it serves hierarchy, copy specific to what the product
does, density appropriate to the audience. Preserve the existing framework and
component APIs — this is a design pass, not a rewrite. After each fix, state what
changed and why it reads as designed-on-purpose.
Acceptance criterion for every mode: **if the result could be swapped onto any other
product without anyone noticing, it fails.**
+40
View File
@@ -0,0 +1,40 @@
---
description: Layered security audit of the current project (dependencies → secrets → injection → authz → report)
argument-hint: [path or scope, e.g. src/api — omit for whole project]
---
Perform a layered security audit of: $ARGUMENTS (if empty: the whole project).
Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check
`.wolf/buglog.json` for previously found security issues before re-reporting them.
Work through the layers in order. For each, report findings before moving on:
## Layer 1 — Dependencies
Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …).
Flag known-vulnerable versions and unmaintained packages that handle untrusted input.
## Layer 2 — Secrets
Search for hardcoded credentials: API keys, tokens, passwords, connection strings,
private keys. Check committed env files, config files, and test fixtures. Verify
`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*).
## Layer 3 — Injection surfaces
Find every place external input reaches an interpreter: shell commands built by string
interpolation (exec/execSync with template strings), SQL string concatenation, HTML
injection/XSS sinks, path traversal (user input joined into fs paths), deserialization
of untrusted data, SSRF (user-controlled URLs fetched server-side).
## Layer 4 — AuthN / AuthZ
Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object
IDs without ownership checks), privilege escalation paths, session handling, CORS and
CSRF posture, servers bound to 0.0.0.0 without auth.
## Layer 5 — Report
Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack
scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag
"security". Offer to fix Critical and High items immediately.
Rules: verify each finding against the actual code before reporting (no
pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing
security to silence a warning.
+10
View File
@@ -0,0 +1,10 @@
---
description: OpenWolf protocol enforcement, active on all files
globs: **/*
---
- To locate a symbol or file, run `openwolf find <name>` first (ranked shortlist, under 1k tokens). For one file's description and symbol ranges: `openwolf find --file <path>`. Never read .wolf/anatomy.md whole; it is an index.
- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code (grep "## Do-Not-Repeat"); after a user correction, update cerebrum.md immediately.
- Do NOT manually update .wolf/anatomy.md or .wolf/memory.md; the OpenWolf hooks maintain them.
- BEFORE fixing any bug: run `openwolf bug search "<error>"` or grep .wolf/buglog.json. AFTER fixing one: log it there (error_message, root_cause, fix, tags).
- When resuming a session, read .wolf/STATUS.md first; regenerate it with /handoff when a quest finishes.
+144
View File
@@ -0,0 +1,144 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-start.js\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/user-prompt-submit.js\"",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-read.js\"",
"timeout": 5
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-write.js\"",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-bash.js\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-read.js\"",
"timeout": 5
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-write.js\"",
"timeout": 10
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-bash.js\"",
"timeout": 10
}
]
}
],
"PostToolBatch": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-batch.js\"",
"timeout": 5
}
]
}
],
"PreCompact": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/precompact.js\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/stop.js\"",
"timeout": 10
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-end.js\"",
"timeout": 10
}
]
}
]
},
"statusLine": {
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/visibility-statusline.js\""
}
}
+47
View File
@@ -0,0 +1,47 @@
---
name: openwolf
description: OpenWolf operating protocol for this project. Load when starting multi-file work, resuming a session, wrapping up a session, or when unsure how to use .wolf/ state files (anatomy, cerebrum, memory, buglog, STATUS).
---
# OpenWolf Operating Protocol
You are working in an OpenWolf-managed project. The hooks handle bookkeeping automatically: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed.
## Session resume
`.wolf/STATUS.md` is the handoff document. Read it first when resuming; it replaces re-reading memory, plans, and code to reconstruct context. Regenerate it on demand with `/handoff` when a quest finishes or before suggesting `/clear`.
## File navigation
1. To locate a symbol or file by name, run `openwolf find <query>` first: a ranked shortlist from the index (~1k tokens max), cheaper than grepping the world. For one file's description, size, and symbol line ranges: `openwolf find --file <path>`.
2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit; the pre-read hook surfaces the largest sections with line ranges, and `openwolf map` prints a token-budgeted overview of the most important files.
3. Never read `.wolf/anatomy.md` whole; it is an index. Grep it only for a single path's line when `find` is unavailable.
4. If a file is not indexed, search with Grep/Glob. Regenerate the index with `openwolf scan`.
## Code generation and learning
1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`.
2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session. The bar is LOW; a missing entry repeats the discovery next session.
## Bug logging
Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known.
After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right.
## Token discipline
- Never re-read a file already read this session unless it changed since.
- Prefer anatomy descriptions and targeted Grep over full file reads.
- If appending to a file, do not read the entire file first.
## Session end
Before wrapping up: run `/handoff` (or update `.wolf/STATUS.md` by hand), write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs in cerebrum.md / buglog.json.
## On-demand skills
- `/handoff`: regenerate .wolf/STATUS.md from the session's actual state.
- `/designqc`: screenshot-based design review of the running app.
- `/reframe`: UI framework selection, migration, and anti-generic design audits.
- `/security-audit`: security review of the project.
+2
View File
@@ -0,0 +1,2 @@
[features]
hooks = true
+139
View File
@@ -0,0 +1,139 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-start.js\"",
"timeout": 5,
"statusMessage": "OpenWolf session bootstrap"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-bash.js\"",
"timeout": 5,
"statusMessage": "OpenWolf shell precheck"
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-read.js\"",
"timeout": 5,
"statusMessage": "OpenWolf read precheck"
}
]
},
{
"matcher": "Edit|Write|MultiEdit|apply_patch",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-write.js\"",
"timeout": 5,
"statusMessage": "OpenWolf write precheck"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-bash.js\"",
"timeout": 10,
"statusMessage": "OpenWolf shell tracking"
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-read.js\"",
"timeout": 5,
"statusMessage": "OpenWolf read tracking"
}
]
},
{
"matcher": "Edit|Write|MultiEdit|apply_patch",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-write.js\"",
"timeout": 10,
"statusMessage": "OpenWolf anatomy update"
}
]
}
],
"PreCompact": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/precompact.js\"",
"timeout": 5,
"statusMessage": "OpenWolf compaction snapshot"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-end.js\"",
"timeout": 3,
"statusMessage": "OpenWolf final flush"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/user-prompt-submit.js\"",
"timeout": 5,
"statusMessage": "OpenWolf context refresh"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/stop.js\"",
"timeout": 10,
"statusMessage": "OpenWolf session wrap-up"
}
]
}
]
}
}
+26
View File
@@ -0,0 +1,26 @@
---
description: Screenshot-based design review of the running app via openwolf designqc
argument-hint: [--url <url>] [--routes <routes>]
---
Arguments: $ARGUMENTS
Evaluate and improve the design/UI of this app:
1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above).
- The command auto-detects a running dev server, or starts one from package.json if needed.
- Use `--url <url>` only if auto-detection fails.
- Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom).
2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool.
3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns):
- Spacing and whitespace consistency
- Typography hierarchy and readability
- Color contrast and accessibility (WCAG)
- Visual hierarchy and focal points
- Component consistency
- Whether the design looks generic ("white-coded", no personality)
4. Provide specific, actionable feedback with fix suggestions.
5. If the user approves, implement the fixes directly in their code.
6. Re-run `openwolf designqc` to verify the improvement.
Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures.
+25
View File
@@ -0,0 +1,25 @@
Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS
Build it from the session's actual state, not from memory of the conversation alone:
1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items.
2. Run `git status --short` and `git log --oneline -8` to see what actually changed.
3. Skim the latest session block of `.wolf/memory.md` for the action log.
Then rewrite `.wolf/STATUS.md` with:
- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail.
- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make.
- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks).
- Bump the date.
Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one.
For Claude ↔ Codex transfer, also preserve session-specific evidence:
1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task.
2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent <agent> --session <id> --file <json>`. Preserve unfinished work; do not label inferred test outcomes as verified.
3. Run `openwolf handoff export --from <agent> --session <id> --to <destination> --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session.
4. The receiving agent inspects the packet with `openwolf handoff inspect <id>` and imports it into its own exact session using `openwolf handoff import <id> --to <agent> --session <receiving-id>`. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets.
5. Use `openwolf handoff recover --from <agent> --session <id>` if hooks were missed, and `openwolf handoff search "<error or symbol>"` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules.
+41
View File
@@ -0,0 +1,41 @@
---
description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles
argument-hint: [migrate [framework] | audit [target] | fix [target]]
---
Arguments: $ARGUMENTS
Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles
(anti-generic mandate)**, the framework knowledge base, and the migration prompts.
Use `.wolf/anatomy.md` to locate UI files instead of scanning.
Pick the mode from the arguments (default: `migrate` if a framework is named or the
user is choosing one; otherwise ask which mode they want):
## Mode: migrate [framework]
Framework selection and migration.
1. If no framework is named, ask the Decision Questions from the knowledge file
(stop early once the answer narrows to 1–2 options) and recommend one.
2. Use that framework's prompt from the knowledge file, adapted to this project's
real structure via `.wolf/anatomy.md`.
3. The Design Principles override anything generic in the prompt: no template
hero→features→CTA structures, no stock palettes — distinctive by default.
## Mode: audit [target]
Walk the target (default: the whole UI) and flag every match against the AI-tell
blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything,
emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for
every role, template SaaS structure, filler microcopy. Produce a findings table —
component, tell matched, severity, specific replacement direction — and end with the
3 changes that would most increase distinctiveness.
## Mode: fix [target]
Run the audit, then fix findings in severity order. Fixes must move toward, not merely
away: typography chosen with intent, a palette derived from the product's actual
brand/domain, asymmetry where it serves hierarchy, copy specific to what the product
does, density appropriate to the audience. Preserve the existing framework and
component APIs — this is a design pass, not a rewrite. After each fix, state what
changed and why it reads as designed-on-purpose.
Acceptance criterion for every mode: **if the result could be swapped onto any other
product without anyone noticing, it fails.**
+40
View File
@@ -0,0 +1,40 @@
---
description: Layered security audit of the current project (dependencies → secrets → injection → authz → report)
argument-hint: [path or scope, e.g. src/api — omit for whole project]
---
Perform a layered security audit of: $ARGUMENTS (if empty: the whole project).
Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check
`.wolf/buglog.json` for previously found security issues before re-reporting them.
Work through the layers in order. For each, report findings before moving on:
## Layer 1 — Dependencies
Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …).
Flag known-vulnerable versions and unmaintained packages that handle untrusted input.
## Layer 2 — Secrets
Search for hardcoded credentials: API keys, tokens, passwords, connection strings,
private keys. Check committed env files, config files, and test fixtures. Verify
`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*).
## Layer 3 — Injection surfaces
Find every place external input reaches an interpreter: shell commands built by string
interpolation (exec/execSync with template strings), SQL string concatenation, HTML
injection/XSS sinks, path traversal (user input joined into fs paths), deserialization
of untrusted data, SSRF (user-controlled URLs fetched server-side).
## Layer 4 — AuthN / AuthZ
Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object
IDs without ownership checks), privilege escalation paths, session handling, CORS and
CSRF posture, servers bound to 0.0.0.0 without auth.
## Layer 5 — Report
Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack
scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag
"security". Offer to fix Critical and High items immediately.
Rules: verify each finding against the actual code before reporting (no
pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing
security to silence a warning.
+8
View File
@@ -0,0 +1,8 @@
---
description: OpenWolf project protocol (context management via .wolf/)
alwaysApply: true
---
# OpenWolf
This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index).
+7
View File
@@ -0,0 +1,7 @@
/target
**/*.rs.bk
*.pcap
*.cap
.sabia-audit.log
node_modules/
apps/gui/src-tauri/target
+26
View File
@@ -0,0 +1,26 @@
---
description: Screenshot-based design review of the running app via openwolf designqc
argument-hint: [--url <url>] [--routes <routes>]
---
Arguments: $ARGUMENTS
Evaluate and improve the design/UI of this app:
1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above).
- The command auto-detects a running dev server, or starts one from package.json if needed.
- Use `--url <url>` only if auto-detection fails.
- Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom).
2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool.
3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns):
- Spacing and whitespace consistency
- Typography hierarchy and readability
- Color contrast and accessibility (WCAG)
- Visual hierarchy and focal points
- Component consistency
- Whether the design looks generic ("white-coded", no personality)
4. Provide specific, actionable feedback with fix suggestions.
5. If the user approves, implement the fixes directly in their code.
6. Re-run `openwolf designqc` to verify the improvement.
Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures.
+25
View File
@@ -0,0 +1,25 @@
Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS
Build it from the session's actual state, not from memory of the conversation alone:
1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items.
2. Run `git status --short` and `git log --oneline -8` to see what actually changed.
3. Skim the latest session block of `.wolf/memory.md` for the action log.
Then rewrite `.wolf/STATUS.md` with:
- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail.
- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make.
- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks).
- Bump the date.
Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one.
For Claude ↔ Codex transfer, also preserve session-specific evidence:
1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task.
2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent <agent> --session <id> --file <json>`. Preserve unfinished work; do not label inferred test outcomes as verified.
3. Run `openwolf handoff export --from <agent> --session <id> --to <destination> --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session.
4. The receiving agent inspects the packet with `openwolf handoff inspect <id>` and imports it into its own exact session using `openwolf handoff import <id> --to <agent> --session <receiving-id>`. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets.
5. Use `openwolf handoff recover --from <agent> --session <id>` if hooks were missed, and `openwolf handoff search "<error or symbol>"` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules.
+41
View File
@@ -0,0 +1,41 @@
---
description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles
argument-hint: [migrate [framework] | audit [target] | fix [target]]
---
Arguments: $ARGUMENTS
Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles
(anti-generic mandate)**, the framework knowledge base, and the migration prompts.
Use `.wolf/anatomy.md` to locate UI files instead of scanning.
Pick the mode from the arguments (default: `migrate` if a framework is named or the
user is choosing one; otherwise ask which mode they want):
## Mode: migrate [framework]
Framework selection and migration.
1. If no framework is named, ask the Decision Questions from the knowledge file
(stop early once the answer narrows to 1–2 options) and recommend one.
2. Use that framework's prompt from the knowledge file, adapted to this project's
real structure via `.wolf/anatomy.md`.
3. The Design Principles override anything generic in the prompt: no template
hero→features→CTA structures, no stock palettes — distinctive by default.
## Mode: audit [target]
Walk the target (default: the whole UI) and flag every match against the AI-tell
blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything,
emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for
every role, template SaaS structure, filler microcopy. Produce a findings table —
component, tell matched, severity, specific replacement direction — and end with the
3 changes that would most increase distinctiveness.
## Mode: fix [target]
Run the audit, then fix findings in severity order. Fixes must move toward, not merely
away: typography chosen with intent, a palette derived from the product's actual
brand/domain, asymmetry where it serves hierarchy, copy specific to what the product
does, density appropriate to the audience. Preserve the existing framework and
component APIs — this is a design pass, not a rewrite. After each fix, state what
changed and why it reads as designed-on-purpose.
Acceptance criterion for every mode: **if the result could be swapped onto any other
product without anyone noticing, it fails.**
+40
View File
@@ -0,0 +1,40 @@
---
description: Layered security audit of the current project (dependencies → secrets → injection → authz → report)
argument-hint: [path or scope, e.g. src/api — omit for whole project]
---
Perform a layered security audit of: $ARGUMENTS (if empty: the whole project).
Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check
`.wolf/buglog.json` for previously found security issues before re-reporting them.
Work through the layers in order. For each, report findings before moving on:
## Layer 1 — Dependencies
Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …).
Flag known-vulnerable versions and unmaintained packages that handle untrusted input.
## Layer 2 — Secrets
Search for hardcoded credentials: API keys, tokens, passwords, connection strings,
private keys. Check committed env files, config files, and test fixtures. Verify
`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*).
## Layer 3 — Injection surfaces
Find every place external input reaches an interpreter: shell commands built by string
interpolation (exec/execSync with template strings), SQL string concatenation, HTML
injection/XSS sinks, path traversal (user input joined into fs paths), deserialization
of untrusted data, SSRF (user-controlled URLs fetched server-side).
## Layer 4 — AuthN / AuthZ
Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object
IDs without ownership checks), privilege escalation paths, session handling, CORS and
CSRF posture, servers bound to 0.0.0.0 without auth.
## Layer 5 — Report
Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack
scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag
"security". Offer to fix Critical and High items immediately.
Rules: verify each finding against the actual code before reporting (no
pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing
security to silence a warning.
+19
View File
@@ -0,0 +1,19 @@
// OpenWolf plugin entry — implementation lives in ./openwolf/.
import {OpenWolf as installed} from "./openwolf/index.js"
import {pinRuntime, scheduleUpdate} from "./openwolf/runtime-updates.js"
import {pathToFileURL} from "node:url"
import * as path from "node:path"
import type {Plugin} from "@opencode-ai/plugin"
export const OpenWolf: Plugin = async (ctx) => {
scheduleUpdate(ctx.directory)
// The server's plugin instance remains fixed; no reload during a session.
let pkg: string | undefined
try { pkg = pinRuntime(ctx.directory, "opencode-server:" + process.pid + ":" + Date.now()) } catch {}
if (pkg) {
try {
const next = await import(pathToFileURL(path.join(pkg,"src/templates/opencode-plugin/index.ts")).href)
return await next.OpenWolf(ctx)
} catch {} // retain the installed plugin if the staged module cannot load
}
return installed(ctx)
}
+196
View File
@@ -0,0 +1,196 @@
import { normalizeSession } from "./session-state.js";
export { normalizeSession } from "./session-state.js";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as crypto from "node:crypto";
// Cross-process mutual exclusion for anatomy writers (OPENWOLF-2.0 §F2b).
//
// Mechanism: lockfile created with the "wx" flag (atomic O_EXCL on macOS,
// Linux and Windows, no native deps). The file body records the owner for
// staleness detection. A stale lock (older than STALE_MS, or a dead pid on
// the same host) is stolen via rename-then-unlink: rename is atomic, so of N
// competing stealers exactly one wins and the rest keep waiting.
//
// Callers NEVER block the agent: on budget exhaustion withAnatomyLock returns
// null and the caller skips its update (the next writer converges the state).
// Self-contained on purpose: compiled standalone into the hooks bundle and
// imported directly by tests.
const LOCK_FILE = "anatomy-index.lock";
const STALE_MS = 10_000; // > hook timeout, so a killed hook's lock is reclaimable
export const HOOK_LOCK_BUDGET_MS = 2_000;
export const CLI_LOCK_BUDGET_MS = 5_000;
interface LockBody {
pid: number;
hostname: string;
acquiredAt: number;
nonce?: string;
}
/** Dependency-free synchronous sleep. */
function sleep(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function tryAcquire(lockPath: string): string | null {
const nonce = crypto.randomBytes(16).toString("hex");
try {
const body: LockBody = { pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now(), nonce };
fs.writeFileSync(lockPath, JSON.stringify(body), { flag: "wx" });
return nonce;
} catch { return null; }
}
function isStale(lockPath: string): boolean {
try {
const body = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as LockBody;
// Age never grants permission to evict a live writer.
if (body.hostname !== os.hostname() || !Number.isSafeInteger(body.pid) || body.pid <= 0) return false;
try { process.kill(body.pid, 0); return false; }
catch (err) { return (err as NodeJS.ErrnoException).code === "ESRCH"; }
} catch {
try { return Date.now() - fs.statSync(lockPath).mtimeMs > STALE_MS; }
catch { return false; }
}
}
function trySteal(lockPath: string): void {
// Serialize reapers, then inspect AGAIN. A stale observation made before
// another reaper ran must never rename a new owner's lock (#83).
const reaper = lockPath + ".reap";
let fd: number;
try { fd = fs.openSync(reaper, "wx"); } catch { return; }
try {
if (isStale(lockPath)) {
try { fs.unlinkSync(lockPath); } catch {}
}
} finally {
fs.closeSync(fd);
try { fs.unlinkSync(reaper); } catch {}
}
}
/**
* Run `fn` while holding the named lockfile. Returns fn's result, or null if
* the lock could not be acquired within `budgetMs` (caller must degrade
* gracefully — skip the update, never block). Same mechanism as the anatomy
* lock; used for the other multi-writer JSON files (cron-state, token-ledger).
*/
export function acquireLock(lockPath: string, budgetMs: number): (() => void) | null {
const deadline = Date.now() + budgetMs;
let attempt = 0;
let nonce: string | null = null;
while (true) {
nonce = tryAcquire(lockPath);
if (nonce !== null) break;
if (isStale(lockPath)) trySteal(lockPath);
if (Date.now() >= deadline) return null;
// Start tight, then back off. A contended session-state critical section
// is 1-3 ms, so the old flat 25-50 ms poll meant a 60-way herd needed
// roughly 2 s just to drain — precisely the hook budget, which made the
// slowest few writers give up and skip their update under load. The
// capped exponential keeps long holders (anatomy writes) from spinning.
const base = Math.min(2 * 2 ** Math.min(attempt, 4), 32);
sleep(base + Math.floor(Math.random() * base));
attempt++;
}
return () => {
try {
const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")) as LockBody;
if (owner.nonce === nonce) fs.unlinkSync(lockPath);
} catch {}
}
}
export function withFileLock<T>(lockPath: string, budgetMs: number, fn: () => T): T | null {
const release = acquireLock(lockPath, budgetMs);
if (!release) return null;
try { return fn(); } finally { release(); }
}
/**
* Serialized read-modify-write against a shared JSON file.
*
* Issues #83, #84, #86 and #88, each reported with a fix by @davdittrich
* (PRs #98, #109, #108, #105). This is the one helper those four sites share.
*
* Atomic writes (tmp + rename) prevent TORN files. They do not prevent LOST
* UPDATES: two processes that each read, modify, and write independently both
* produce a valid file, and the second one silently erases the first one's
* change. Measured on real hooks: 60 concurrent post-read processes kept 26 of
* 60 reads (#83); 60 concurrent registrations kept 43 of 60 projects (#88);
* 60 concurrent SessionStart hooks counted 35 of 60 sessions (#84).
*
* The fix is not a better write, it is a serialized transaction. `mutate`
* receives the CURRENT on-disk value, read inside the lock, and its result is
* written before the lock is released. Never apply a delta to a snapshot read
* before calling this.
*
* Returns the written value, or null if the lock could not be acquired within
* `budgetMs` — callers must degrade, never block the agent. There is no
* unlocked fallback on purpose: writing anyway on timeout defeats the lock for
* every well-behaved writer (#86).
*/
export function mutateJSON<T>(
filePath: string,
fallback: T,
budgetMs: number,
mutate: (current: T) => T | void,
): T | null {
// The lockfile lives next to the target, so its directory has to exist
// BEFORE the first acquire attempt. Without this, a session file whose
// directory has not been created yet can never take its own lock: every
// tryAcquire fails with ENOENT, the whole budget is burned waiting, and the
// update is skipped entirely.
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
} catch {}
return withFileLock(filePath + ".lock", budgetMs, () => {
let current: T;
try {
current = JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
current = fallback;
}
if (/[/\\]hooks[/\\]sessions[/\\][^/\\]+\.json$/.test(filePath)) {
current = normalizeSession(current, path.basename(filePath, ".json")) as T;
}
const returned = mutate(current);
const next = returned === undefined ? current : (returned as T);
writeJSONAtomic(filePath, next);
return next;
});
}
/** tmp + rename, with the Windows fallback used elsewhere in the codebase. */
function writeJSONAtomic(filePath: string, data: unknown): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const body = JSON.stringify(data, null, 2);
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
try {
fs.writeFileSync(tmp, body, "utf-8");
fs.renameSync(tmp, filePath);
} catch {
try { fs.unlinkSync(tmp); } catch {}
throw new Error(`Atomic write failed: ${filePath}`);
}
}
/**
* Run `fn` while holding the anatomy lock. Returns fn's result, or null if
* the lock could not be acquired within `budgetMs`.
*/
export function withAnatomyLock<T>(wolfDir: string, budgetMs: number, fn: () => T): T | null {
return withFileLock(path.join(wolfDir, LOCK_FILE), budgetMs, fn);
}
/** Complete state at every ingress, including a missed SessionStart (#96 @davdittrich). */
+424
View File
@@ -0,0 +1,424 @@
import * as fs from "node:fs"
import * as path from "node:path"
import type { AnatomyEntry } from "./types.js"
// ── Markdown format (canonical — mirrors src/hooks/anatomy-store.ts) ────────
// The auto-generated header block: never captured as preamble, or every
// import→render cycle would duplicate it.
const AUTO_HEADER = /^(?:# anatomy\.md\s*$|> Auto-maintained by OpenWolf\.|> Files:\s*\d+\s*tracked)/
// Legacy symbol sub-bullets (2.0.0–2.0.3 renders): mechanical output, dropped
// rather than preserved. Any OTHER indented bullet is hand-written and kept.
const SYMBOL_SUBBULLET = /^ {2}- (?:fn|class|method|section) `[^`]+` L\d+-\d+ \(~\d+ tok\)$/
/** Strip leading/trailing blank lines so preserved blocks don't grow by two lines per write. */
function trimBlankEdges(lines: string[]): string[] {
let start = 0
let end = lines.length
while (start < end && lines[start].trim() === "") start++
while (end > start && lines[end - 1].trim() === "") end--
return lines.slice(start, end)
}
export function parseAnatomyWithRaw(content: string): {
sections: Map<string, AnatomyEntry[]>
rawLines: Map<string, string[]>
preamble: string[]
} {
const sections = new Map<string, AnatomyEntry[]>()
const rawLines = new Map<string, string[]>()
const preamble: string[] = []
let currentSection = ""
for (const raw of content.split("\n")) {
const line = raw.replace(/\r$/, "")
const sm = line.match(/^## (.+)/)
if (sm) {
currentSection = sm[1].trim()
if (!sections.has(currentSection)) sections.set(currentSection, [])
continue
}
if (!currentSection) {
// Above the first section heading: preserve everything hand-written
// (issue #61), skipping only our own generated header block.
if (!AUTO_HEADER.test(line)) preamble.push(line)
continue
}
const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/)
if (em) {
sections.get(currentSection)!.push({
file: em[1],
description: em[2] || "",
tokens: parseInt(em[3], 10),
})
continue
}
if (line.trim() === "") continue
if (SYMBOL_SUBBULLET.test(line)) continue
if (!rawLines.has(currentSection)) rawLines.set(currentSection, [])
rawLines.get(currentSection)!.push(line)
}
return { sections, rawLines, preamble: trimBlankEdges(preamble) }
}
export function parseAnatomy(content: string): Map<string, AnatomyEntry[]> {
return parseAnatomyWithRaw(content).sections
}
// serializeAnatomy was removed: it could not carry rawLines or preamble, and
// every writer must go through renderStore so preserved content survives
// (issue #61).
export function extractDescription(filePath: string): string {
const MAX_DESC = 150
const basename = path.basename(filePath)
const ext = path.extname(basename).toLowerCase()
const known: Record<string, string> = {
"package.json": "Node.js package manifest",
"tsconfig.json": "TypeScript configuration",
".gitignore": "Git ignore rules",
"README.md": "Project documentation",
}
if (known[basename]) return known[basename]
let content: string
try {
const fd = fs.openSync(filePath, "r")
const buf = Buffer.alloc(12288)
const n = fs.readSync(fd, buf, 0, 12288, 0)
fs.closeSync(fd)
content = buf.subarray(0, n).toString("utf-8")
} catch {
return ""
}
if (!content.trim()) return ""
const cap = (s: string) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "..."
if (ext === ".md" || ext === ".mdx") {
const m = content.match(/^#{1,2}\s+(.+)$/m)
if (m) return cap(m[1].trim())
}
if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") {
if (basename === "page.tsx" || basename === "page.js") return "Next.js page component"
if (basename === "layout.tsx" || basename === "layout.js") return "Next.js layout"
const exports = (content.match(/export\s+(?:async\s+)?(?:function|class|const|interface|type|enum)\s+(\w+)/g) || [])
.map(e => e.match(/(\w+)$/)?.[1]).filter(Boolean) as string[]
if (exports.length > 0 && exports.length <= 5) return `Exports ${exports.join(", ")}`
if (exports.length > 5) return cap(`Exports ${exports.slice(0, 4).join(", ")} + ${exports.length - 4} more`)
}
const declM = content.match(/(?:function|class|const|interface|type|enum)\s+(\w+)/)
if (declM) return `Declares ${declM[1]}`
return ""
}
// ── Durable store + lock (mirrors src/hooks/anatomy-store.ts, F2b) ──────────
import * as crypto from "node:crypto"
import * as os from "node:os"
export const STORE_FILE = "anatomy-index.json"
const LOCK_STALE_MS = 10_000
export const LOCK_BUDGET_MS = 2_000
export function sha256(text: string): string {
return crypto.createHash("sha256").update(text).digest("hex")
}
export interface SymbolEntry {
name: string
kind: "fn" | "class" | "method" | "section"
startLine: number
endLine: number
tokens: number
}
export interface StoreFileEntry {
description: string
tokens: number
/** sha256 (first 16 hex chars) of file content when last indexed. */
hash?: string
size?: number
mtimeMs?: number
updatedAt: string
source: "hook" | "scan" | "md-import"
symbols?: SymbolEntry[]
}
export interface AnatomyStoreData {
version: 1
meta: {
lastScanned: string
fileCount: number
hits: number
misses: number
/** sha256 of the markdown this store last rendered — skew detection key. */
renderedHash: string
storeUpdatedAt: string
}
/** Keyed by full normalized relative path, e.g. "src/hooks/shared.ts". */
files: Record<string, StoreFileEntry>
/** Non-conforming anatomy.md lines preserved verbatim, keyed by section. */
rawLines?: Record<string, string[]>
/**
* Hand-written content above the first `## ` heading (issue #61). A typed
* field rather than a reserved rawLines key: old compiled hooks still
* installed in projects ignore an unknown field and round-trip it safely,
* whereas a magic "" section key would render as a stray `## ` heading.
*/
preamble?: string[]
}
export function newStore(): AnatomyStoreData {
const now = new Date().toISOString()
return { version: 1, meta: { lastScanned: now, fileCount: 0, hits: 0, misses: 0, renderedHash: "", storeUpdatedAt: now }, files: {} }
}
export function loadStore(wolfDir: string): AnatomyStoreData | null {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(wolfDir, STORE_FILE), "utf-8"))
if (parsed && parsed.version === 1 && parsed.files && parsed.meta) return parsed as AnatomyStoreData
return null
} catch {
return null
}
}
export function saveStore(wolfDir: string, store: AnatomyStoreData): void {
store.meta.fileCount = Object.keys(store.files).length
store.meta.storeUpdatedAt = new Date().toISOString()
const filePath = path.join(wolfDir, STORE_FILE)
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
const body = JSON.stringify(store, null, 2)
try {
fs.writeFileSync(tmp, body, "utf-8")
fs.renameSync(tmp, filePath)
} catch {
try { fs.writeFileSync(filePath, body, "utf-8") } catch {}
try { fs.unlinkSync(tmp) } catch {}
}
}
/** Section key for a relpath: "src/hooks/" or "./" for root files. */
export function sectionKeyOf(relPath: string): string {
const dir = path.dirname(relPath).split(path.sep).join("/")
return dir === "." ? "./" : dir + "/"
}
/**
* Render the store to markdown, byte-identical to the legacy format. Symbols
* deliberately do NOT render: they live in anatomy-index.json and reach the
* model through the per-file pre-read hint. Rendering them roughly 2.4x'd
* anatomy.md, and agents instructed to consult anatomy.md paid that bill
* wholesale every session.
*/
export function renderStore(store: AnatomyStoreData): string {
const bySection = new Map<string, Array<{ file: string; entry: StoreFileEntry }>>()
for (const [relPath, entry] of Object.entries(store.files)) {
const key = sectionKeyOf(relPath)
if (!bySection.has(key)) bySection.set(key, [])
bySection.get(key)!.push({ file: relPath.slice(relPath.lastIndexOf("/") + 1), entry })
}
const lines: string[] = [
"# anatomy.md",
"",
`> Auto-maintained by OpenWolf. Last scanned: ${store.meta.lastScanned}`,
`> Files: ${Object.keys(store.files).length} tracked | Anatomy hits: ${store.meta.hits} | Misses: ${store.meta.misses}`,
"",
]
const preamble = trimBlankEdges(store.preamble ?? [])
if (preamble.length > 0) {
lines.push(...preamble, "")
}
const rawBySection = store.rawLines ?? {}
const keys = [...new Set([...bySection.keys(), ...Object.keys(rawBySection)])].sort()
for (const key of keys) {
lines.push(`## ${key}`)
lines.push("")
const raws = rawBySection[key] ?? []
for (const raw of raws) {
lines.push(raw)
}
const entries = (bySection.get(key) ?? []).sort((a, b) => a.file.localeCompare(b.file))
if (raws.length > 0 && entries.length > 0) lines.push("")
for (const { file, entry } of entries) {
const desc = entry.description ? ` — ${entry.description}` : ""
lines.push(`- \`${file}\`${desc} (~${entry.tokens} tok)`)
}
lines.push("")
}
return lines.join("\n")
}
/** Write the rendered markdown atomically and pin its hash in the store. */
export function renderToFile(wolfDir: string, store: AnatomyStoreData): void {
const content = renderStore(store)
store.meta.renderedHash = sha256(content)
const anatomyPath = path.join(wolfDir, "anatomy.md")
const tmp = anatomyPath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
try {
fs.writeFileSync(tmp, content, "utf-8")
fs.renameSync(tmp, anatomyPath)
} catch {
try { fs.writeFileSync(anatomyPath, content, "utf-8") } catch {}
try { fs.unlinkSync(tmp) } catch {}
}
}
/**
* Absorb out-of-band edits to anatomy.md (old compiled hooks, agent/human
* hand-edits) into the store. ADDITIVE-ONLY:
* - md entry differs → md wins description/tokens (newer intent)
* - md entry absent from store → added (source "md-import")
* - store entry absent from md → KEPT unless the file is gone from disk
* (deletions are exclusively the full scanner's job)
* Symbols always survive (they only flow store → render).
*/
export function importFromMarkdown(store: AnatomyStoreData, mdContent: string, projectRoot: string): void {
// Corruption guard: an empty/unreadable anatomy.md must never wipe the
// preserved hand-written content in the store.
if (!mdContent.trim()) return
const { sections, rawLines, preamble } = parseAnatomyWithRaw(mdContent)
if (rawLines.size > 0) {
store.rawLines = Object.fromEntries(rawLines)
} else {
delete store.rawLines
}
if (preamble.length > 0) {
store.preamble = preamble
} else {
delete store.preamble
}
const seen = new Set<string>()
for (const [sectionKey, entries] of sections) {
const dir = sectionKey === "./" ? "" : sectionKey
for (const e of entries) {
const relPath = (dir + e.file).split("\\").join("/")
seen.add(relPath)
const existing = store.files[relPath]
if (!existing) {
store.files[relPath] = { description: e.description, tokens: e.tokens, updatedAt: new Date().toISOString(), source: "md-import" }
} else if (existing.description !== e.description || existing.tokens !== e.tokens) {
existing.description = e.description
existing.tokens = e.tokens
existing.updatedAt = new Date().toISOString()
existing.source = "md-import"
}
}
}
// Entries the md no longer lists: keep unless the file is really gone.
for (const relPath of Object.keys(store.files)) {
if (seen.has(relPath)) continue
if (!fs.existsSync(path.join(projectRoot, relPath))) delete store.files[relPath]
}
}
/**
* Read-side lookup: resolve a file to its anatomy entry. Store-first with an
* O(1) relpath key; falls back to a suffix scan (paths outside the root) and
* finally to parsing anatomy.md for projects that predate the store.
* `normalizedFile` and `projectDir` use forward slashes.
*/
export function lookupEntry(
wolfDir: string,
projectDir: string,
normalizedFile: string
): { file: string; description: string; tokens: number; symbols?: SymbolEntry[]; size?: number; mtimeMs?: number } | null {
const rel = normalizedFile.startsWith(projectDir + "/")
? normalizedFile.slice(projectDir.length + 1)
: normalizedFile.startsWith("/") ? null : normalizedFile
const store = loadStore(wolfDir)
if (store) {
const toResult = (rp: string, e: StoreFileEntry) => ({
file: rp.slice(rp.lastIndexOf("/") + 1),
description: e.description,
tokens: e.tokens,
symbols: e.symbols,
size: e.size,
mtimeMs: e.mtimeMs,
})
const hit = rel ? store.files[rel] : undefined
if (hit) return toResult(rel!, hit)
for (const [rp, e] of Object.entries(store.files)) {
if (normalizedFile === rp || normalizedFile.endsWith("/" + rp)) {
return toResult(rp, e)
}
}
return null
}
// Pre-store project: legacy markdown scan.
let md: string
try {
md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8")
} catch {
return null
}
for (const [sectionKey, entries] of parseAnatomy(md)) {
const dir = sectionKey === "./" ? "" : sectionKey
for (const entry of entries) {
const entryRelPath = (dir + entry.file).split("\\").join("/")
if (normalizedFile === entryRelPath || normalizedFile.endsWith("/" + entryRelPath)) {
return entry
}
}
}
return null
}
/**
* Standard writer entry point: load the store (bootstrapping from anatomy.md
* on first contact), and absorb any md-side divergence before the caller
* mutates. Call ONLY while holding the anatomy lock.
*/
export function loadStoreReconciled(wolfDir: string, projectRoot: string): AnatomyStoreData {
let store = loadStore(wolfDir)
let md: string | null = null
try { md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8") } catch {}
if (!store) {
store = newStore()
if (md) importFromMarkdown(store, md, projectRoot)
return store
}
if (md !== null && sha256(md) !== store.meta.renderedHash) importFromMarkdown(store, md, projectRoot)
return store
}
function lockSleep(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
}
export function withAnatomyLock<T>(wolfDir: string, budgetMs: number, fn: () => T): T | null {
const lockPath = path.join(wolfDir, "anatomy-index.lock")
const deadline = Date.now() + budgetMs
while (true) {
try {
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now() }), { flag: "wx" })
break
} catch {}
let stale = false
try {
const body = JSON.parse(fs.readFileSync(lockPath, "utf-8"))
stale = typeof body.acquiredAt !== "number" || Date.now() - body.acquiredAt > LOCK_STALE_MS
if (!stale && body.hostname === os.hostname() && typeof body.pid === "number") {
try { process.kill(body.pid, 0) } catch (err) { stale = (err as NodeJS.ErrnoException).code === "ESRCH" }
}
} catch {
try { stale = Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {}
}
if (stale) {
const graveyard = lockPath + "." + crypto.randomBytes(4).toString("hex") + ".stale"
try { fs.renameSync(lockPath, graveyard); try { fs.unlinkSync(graveyard) } catch {} } catch {}
}
if (Date.now() >= deadline) return null
lockSleep(25 + Math.floor(Math.random() * 25))
}
try {
return fn()
} finally {
try { fs.unlinkSync(lockPath) } catch {}
}
}
+13
View File
@@ -0,0 +1,13 @@
/** PR #69 @liveoakwag, extended with a persisted high-water mark.
* Caller MUST hold buglog.json.lock while allocating and persisting.
*/
export function nextBugId(log: { bugs: Array<{ id?: unknown }>; next_id?: number }): string {
let next = Number.isSafeInteger(log.next_id) && log.next_id! > 0 ? log.next_id! : 1;
for (const bug of log.bugs) {
const match = /^bug-(\d+)$/.exec(String(bug?.id ?? ""));
if (match && Number.isSafeInteger(Number(match[1]))) next = Math.max(next, Number(match[1]) + 1);
}
if (!Number.isSafeInteger(next + 1)) throw new Error("Bug ID space exhausted");
log.next_id = next + 1;
return `bug-${String(next).padStart(3, "0")}`;
}
+43
View File
@@ -0,0 +1,43 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {withFileLock,HOOK_LOCK_BUDGET_MS} from './anatomy-lock.js';
import {nextBugId} from './bug-id.js';
import {sharedWolfDir} from './knowledge-root.js';
export interface BugObservation {error_message:string;file:string;root_cause:string;fix:string;tags:string[];line?:number;status?:string;observed_worktree?:string;}
export function recordBug(wolfDir:string,bug:BugObservation):void {
const dir=path.join(wolfDir,'bug-pending');fs.mkdirSync(dir,{recursive:true});
const id=crypto.randomUUID();const file=path.join(dir,id+'.json');
const fd=fs.openSync(file,'wx',0o600);
try {fs.writeFileSync(fd,JSON.stringify({id,at:new Date().toISOString(),bug}));fs.fsyncSync(fd);} finally {fs.closeSync(fd);}
reconcileBugs(wolfDir);
}
export function reconcileBugs(wolfDir:string):boolean {
const pending=path.join(wolfDir,'bug-pending');let names:string[];
try {names=fs.readdirSync(pending).filter(n=>n.endsWith('.json'));} catch {return true;}
if (!names.length) return true;
const file=path.join(sharedWolfDir(wolfDir),'buglog.json');
const ok=withFileLock(file+'.lock',HOOK_LOCK_BUDGET_MS,()=>{
let log:any={version:1,bugs:[]};
try {log=JSON.parse(fs.readFileSync(file,'utf8'));} catch(e) {if ((e as NodeJS.ErrnoException).code!=='ENOENT') throw e;}
if (Array.isArray(log)) log={version:1,bugs:log};
if (!log || !Array.isArray(log.bugs)) throw new Error('Invalid buglog; observations retained without overwriting it');
log.applied_observations ??= {};
for (const name of names) {
let event:any;
try {event=JSON.parse(fs.readFileSync(path.join(pending,name),'utf8'));} catch(e) {if ((e as NodeJS.ErrnoException).code==='ENOENT') continue;throw e;}
if (!event.id || !event.bug || typeof event.bug.file!=='string') throw new Error('Invalid bug observation retained');
if (log.applied_observations[event.id]) continue;
const b=event.bug as BugObservation;
const existing=log.bugs.find((old:any)=>old?.file===b.file && old.error_message===b.error_message && old.fix===b.fix);
if (existing) {existing.occurrences=(Number.isSafeInteger(existing.occurrences)?existing.occurrences:0)+1;existing.last_seen=event.at;}
else log.bugs.push({...b,id:nextBugId(log),timestamp:event.at,last_seen:event.at,occurrences:1,related_bugs:[]});
log.applied_observations[event.id]=true;
}
const tmp=file+'.'+crypto.randomUUID()+'.tmp';
try {const fd=fs.openSync(tmp,'wx',0o600);try {fs.writeFileSync(fd,JSON.stringify(log,null,2));fs.fsyncSync(fd);}finally{fs.closeSync(fd);}fs.renameSync(tmp,file);}finally{try{fs.unlinkSync(tmp);}catch{}}
for(const name of names){try{fs.unlinkSync(path.join(pending,name));}catch{}}
return true;
});
return ok===true;
}
@@ -0,0 +1,76 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { mutateJSON, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js";
export interface ReadObservation {
id: string;
file: string;
at: string;
tokens: number;
fingerprint?: string;
mtime?: number;
internal?: boolean;
kind?: "read" | "write";
action?: string;
editKey?: string;
}
/** One immutable event per file: no contended append, and no shared offset. */
export function persistRead(sessionFile: string, event: ReadObservation): void {
const dir = sessionFile + ".events";
fs.mkdirSync(dir, { recursive: true });
const dest = path.join(dir, crypto.createHash("sha256").update(event.id).digest("hex") + ".json");
const tmp = dest + "." + crypto.randomUUID() + ".tmp";
try {
const fd = fs.openSync(tmp, "wx", 0o600);
try { fs.writeFileSync(fd, JSON.stringify(event)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
// A retry of the same upstream event cannot replace an already persisted event.
try { fs.linkSync(tmp, dest); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; }
} finally { try { fs.unlinkSync(tmp); } catch {} }
}
export function reconcileReads(sessionFile: string, budget = HOOK_LOCK_BUDGET_MS): boolean {
const dir = sessionFile + ".events";
let names: string[];
try { names = fs.readdirSync(dir).filter(n => n.endsWith(".json")); } catch { return true; }
if (!names.length) return true;
const done: string[] = [];
const result = mutateJSON<Record<string, any>>(sessionFile, { files_read: {} }, budget, s => {
s.applied_read_events ??= {};
s.files_read ??= {};
const events = names.flatMap(name => {
try { return [{ name, event: JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")) as ReadObservation }]; }
catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return []; throw e; }
}).sort((a,b) => a.event.at.localeCompare(b.event.at));
for (const { name, event: e } of events) {
if (!s.applied_read_events[e.id]) {
if (e.kind === "write") {
s.files_written ??= [];
s.edit_counts ??= {};
s.files_written.push({file:e.file,action:e.action ?? "edit",tokens:e.tokens,at:e.at});
const key=e.editKey ?? e.file;
s.edit_counts[key]=(s.edit_counts[key] ?? 0)+1;
delete s.files_read[e.file];
} else if (e.internal) {
s.wolf_internal_tokens = (s.wolf_internal_tokens ?? 0) + e.tokens;
s.wolf_internal_reads ??= {};
s.wolf_internal_reads[e.file] = (s.wolf_internal_reads[e.file] ?? 0) + e.tokens;
} else {
const previous = s.files_read[e.file];
if (!previous?.observed_at || previous.observed_at <= e.at) {
s.files_read[e.file] = { ...previous, count: previous?.count ?? 1, tokens: e.tokens, first_read: previous?.first_read ?? e.at, observed_at: e.at, ranged: false, read_hash: e.fingerprint, read_mtime: e.mtime };
}
}
s.applied_read_events[e.id] = true;
}
done.push(name);
}
});
if (result === null) return false;
// Receipts remain in the state, so a crash between commit and unlink is safe.
for (const name of done) { try { fs.unlinkSync(path.join(dir, name)); } catch {} }
return true;
}
export const persistObservation = persistRead;
+165
View File
@@ -0,0 +1,165 @@
import { normalizeSession } from "./session-state.js"
import { gcSessionFiles as canonicalGC } from "./shared.js"
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import * as fs from "node:fs"
import * as path from "node:path"
import * as crypto from "node:crypto"
export function getWolfDir(directory: string): string {
return path.join(directory, ".wolf")
}
export function wolfDirExists(directory: string): boolean {
return fs.existsSync(getWolfDir(directory))
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return (
typeof v === "object" &&
v !== null &&
!Array.isArray(v) &&
Object.getPrototypeOf(v) === Object.prototype
)
}
/**
* Recursively fills missing keys in `loaded` from `defaults`.
* Loaded values always win; defaults only fill gaps. Arrays and scalars
* are replaced wholesale (not merged).
*/
function deepMergeDefaults<T>(defaults: T, loaded: T): T {
if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded
const result: Record<string, unknown> = { ...(defaults as Record<string, unknown>) }
for (const key of Object.keys(loaded as Record<string, unknown>)) {
const lv = (loaded as Record<string, unknown>)[key]
const dv = (defaults as Record<string, unknown>)[key]
if (isPlainObject(lv) && isPlainObject(dv)) {
result[key] = deepMergeDefaults(dv, lv)
} else {
result[key] = lv
}
}
return result as T
}
/**
* Reads JSON from `filePath`. If the file exists and parses, its values are
* deep-merged over `fallback` so that missing nested keys fall back to the
* provided defaults (loaded values always win). If the file is missing or
* unparseable, `fallback` is returned as-is.
*
* This prevents `TypeError: Cannot read properties of undefined` when a
* user's file predates a section a newer release reads (e.g. a pre-2.0
* token-ledger.json without `lifetime`).
*/
export function readJSON<T>(filePath: string, fallback: T): T {
try {
const raw = fs.readFileSync(filePath, "utf-8")
const parsed = JSON.parse(raw) as T
const value=deepMergeDefaults(fallback, parsed)
return /[\\/]hooks[\\/]sessions[\\/][^\\/]+\.json$/.test(filePath) ? normalizeSession(value,path.basename(filePath,".json")) as T : value
} catch {
return fallback
}
}
export function writeJSON(filePath: string, data: unknown): void {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
try {
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8")
fs.renameSync(tmp, filePath)
} catch {
try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8") } catch {}
try { fs.unlinkSync(tmp) } catch {}
}
}
/**
* Per-session state path, mirroring getSessionFilePath() in src/hooks/shared.ts
* (same id validation, same layout).
*
* Issue #89, reported with PR #113 by @davdittrich, whose session-file GC is
* adopted below.
*
* Every OpenCode handler receives a sessionId but they all used to persist to
* one shared hooks/_session.json, so starting a second session in the same
* project overwrote the first one's state and each later read or write from
* either session mutated the survivor (#89). The legacy shared file is used
* only when there is no usable id, so existing single-session installs keep
* working.
*/
export function sessionFilePath(hooksDir: string, sessionId: string | undefined): string {
if (typeof sessionId === "string" && /^[\w.-]{4,128}$/.test(sessionId)) {
return path.join(hooksDir, "sessions", `${sessionId}.json`)
}
return path.join(hooksDir, "_session.json")
}
/**
* Delete per-session state files older than maxAgeDays.
*
* Mirrors gcSessionFiles() in src/hooks/shared.ts. Splitting state per session
* fixes cross-session contamination but creates one file per session forever,
* so the directory needs the same bound the main hooks already apply.
*/
export function gcSessionFiles(hooksDir: string, maxAgeDays = 7): void {
canonicalGC(maxAgeDays,path.dirname(hooksDir))
}
export function readMarkdown(filePath: string): string {
try {
return fs.readFileSync(filePath, "utf-8")
} catch {
return ""
}
}
export function appendMarkdown(filePath: string, line: string): void {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const ok = withFileLock(filePath + ".lock", HOOK_LOCK_BUDGET_MS, () => { fs.appendFileSync(filePath, line, "utf-8"); return true })
if (ok === null) {
const pending = filePath + ".pending"
fs.mkdirSync(pending, {recursive:true})
fs.writeFileSync(path.join(pending, `${Date.now()}-${crypto.randomUUID()}.json`), JSON.stringify({line}), {flag:"wx",mode:0o600})
}
}
export function timeShort(): string {
const d = new Date()
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
}
export function timestamp(): string {
return new Date().toISOString()
}
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/")
}
export function estimateTokens(text: string, type: "code" | "prose" | "mixed" = "mixed"): number {
const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75
return Math.ceil(text.length / ratio)
}
// Files whose contents (or content-derived descriptions) must never reach
// anatomy.md / memory.md because they hold secrets (issue #54). Mirrors
// isSensitiveFile in src/hooks/shared.ts.
const SENSITIVE_EXTENSIONS = new Set([
".pem", ".key", ".p8", ".p12", ".pfx", ".keystore", ".jks", ".ppk", ".kdbx", ".tfstate",
])
const SENSITIVE_BASENAMES = new Set([".npmrc", ".netrc", ".htpasswd", ".pgpass"])
export function isSensitiveFile(basename: string): boolean {
const lower = basename.toLowerCase()
if (lower === ".env" || lower.startsWith(".env.")) return true
if (SENSITIVE_BASENAMES.has(lower)) return true
const dot = lower.lastIndexOf(".")
if (dot >= 0 && SENSITIVE_EXTENSIONS.has(lower.slice(dot))) return true
if (/^id_(rsa|dsa|ecdsa|ed25519)/.test(lower)) return true
if (lower.includes("credential") || /^secrets\.(json|ya?ml|toml)$/.test(lower)) return true
return false
}
@@ -0,0 +1,98 @@
import {recordReceipt} from './visibility.js';
/** Small local checkpoints: no transcript scanning, Git calls or model requests. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {mutateJSON} from './anatomy-lock.js';
export const hashText=(s:string)=>crypto.createHash('sha256').update(s).digest('hex');
export function sensitivePath(s:string):boolean{return /(?:^|[/\\])(?:\.env(?:\.[^/\\]+)?|credentials(?:\.json)?|secrets?(?:\.[^/\\]+)?|id_(?:rsa|ed25519)|[^/\\]+\.(?:pem|key|p12|pfx))(?:$|[/\\])/i.test(s)}
export function redact(s:string):string {
return s.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g,'[REDACTED PRIVATE KEY]')
.replace(/\b(?:sk-[\w-]{12,}|gh[pousr]_[\w]{20,}|github_pat_[\w]{20,}|AKIA[A-Z0-9]{16})\b/g,'[REDACTED CREDENTIAL]')
.replace(/((?:authorization\s*[:=]\s*(?:bearer\s+)?|(?:api[_-]?key|access[_-]?token|password|secret)\s*["']?\s*[:=]\s*["']?))[^\s"',;}]+/gi,'$1[REDACTED]')
.replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/g,'$1[REDACTED]@');
}
export interface ActiveState {
schema:1;session:string;agent:string;updated_at:string;objective:string;constraints:string[];
next_action:string;unresolved:string[];completed:string[];recent:Array<{id:string;kind:string;text:string;at:string}>;
applied:Record<string,boolean>;compactions:number;injections:Array<{hash:string;at:string;bytes:number}>;
imported_packet?:string;coverage:string[];last_context?:Record<string,unknown>;
}
export const activeFile=(root:string,agent:string,session:string)=>path.join(root,'.wolf','handoff','active',hashText(agent+':'+session)+'.json');
export function emptyActive(agent:string,session:string):ActiveState{return {schema:1,agent,session,updated_at:'',objective:'',constraints:[],next_action:'',unresolved:[],completed:[],recent:[],applied:{},compactions:0,injections:[],coverage:['Semantic state requires an explicit checkpoint; missed hooks may need transcript recovery.']}}
export interface CheckpointEvent {id:string;kind:string;at:string;text?:string;patch?:Partial<Pick<ActiveState,'objective'|'constraints'|'next_action'|'unresolved'|'completed'|'imported_packet'>>}
export function queueCheckpoint(root:string,agent:string,session:string,event:CheckpointEvent):void {
if(!session||!fs.existsSync(path.join(root,'.wolf')))return;
const dir=activeFile(root,agent,session)+'.events';fs.mkdirSync(dir,{recursive:true});
const file=path.join(dir,hashText(event.id)+'.json'),tmp=file+'.'+crypto.randomUUID()+'.tmp';
const body=JSON.stringify({agent,session,event});
try {const fd=fs.openSync(tmp,'wx',0o600);try{fs.writeFileSync(fd,body);fs.fsyncSync(fd)}finally{fs.closeSync(fd)}try{fs.linkSync(tmp,file)}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e}}finally{try{fs.unlinkSync(tmp)}catch{}}
}
export function reconcileActive(root:string,agent:string,session:string):ActiveState {
const file=activeFile(root,agent,session),dir=file+'.events';let names:string[]=[];
try{names=fs.readdirSync(dir).filter(n=>n.endsWith('.json')).sort()}catch{}
const done:string[]=[];
const result=mutateJSON<ActiveState>(file,emptyActive(agent,session),50,state=>{
const events=names.flatMap(n=>{try{return [{n,...JSON.parse(fs.readFileSync(path.join(dir,n),'utf8'))}]}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return [];throw e}}).sort((a,b)=>a.event.at.localeCompare(b.event.at)||a.event.id.localeCompare(b.event.id));
for(const {n,event:e} of events as Array<{n:string;event:CheckpointEvent}>) {
if(!state.applied[e.id]) {
if(e.patch){
for(const key of ['objective','next_action'] as const)if(typeof e.patch[key]==='string')state[key]=redact(e.patch[key]!).slice(0,4000);
for(const key of ['constraints','unresolved','completed'] as const)if(Array.isArray(e.patch[key]))state[key]=e.patch[key]!.filter(v=>typeof v==='string').slice(0,100).map(v=>redact(v).slice(0,1000));
if(typeof e.patch.imported_packet==='string'&&/^[a-f0-9]{64}$/.test(e.patch.imported_packet))state.imported_packet=e.patch.imported_packet;
}
if(e.kind==='request'&&!state.objective&&e.text)state.objective=redact(e.text).slice(0,4000);
if(e.kind==='compaction')state.compactions++;
state.recent.push({id:e.id,kind:e.kind,text:redact(e.text??'').slice(0,1200),at:e.at});state.recent=state.recent.sort((a,b)=>a.at.localeCompare(b.at)||a.id.localeCompare(b.id)).slice(-24);
state.applied[e.id]=true;if(!state.updated_at||Date.parse(e.at)>Date.parse(state.updated_at))state.updated_at=e.at;
}
done.push(n);
}
});
if(result)for(const n of done)try{fs.unlinkSync(path.join(dir,n))}catch{}
if(result){
for(const e of result.recent)if(done.length&&['semantic-checkpoint','handover-import','compaction'].includes(e.kind))recordReceipt(root,{agent,session,operation:'checkpoint-saved',evidence:agent+':'+session+':'+e.id,at:Date.parse(e.at)});
return result;
}
try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return emptyActive(agent,session)}
}
export function observeCheckpoint(root:string,agent:string,hook:string,input:any):void {
const session=typeof input.session_id==='string'?input.session_id:'';if(!session)return;
let kind='',text='';
if(hook==='precompact')kind='compaction';
if(hook==='session-end')kind='session-end';
if(hook==='stop')kind='turn-end';
if(hook==='user-prompt-submit'){kind='request';text=typeof input.prompt==='string'?input.prompt:''}
if(hook==='post-write'){kind='edit';const f=input.tool_input?.file_path??input.tool_input?.path??'';text=sensitivePath(f)?'[sensitive path omitted]':String(f)}
if(hook==='post-bash'){
const command=input.tool_input?.command;
if(typeof command==='string'&&/\b(test|pytest|vitest|jest|cargo test|go test|build|check)\b/i.test(command)){kind='validation-observation';text=`Command: ${command.slice(0,600)}; reported exit: ${input.tool_response?.exit_code??input.tool_response?.exitCode??'unavailable'}. Repository-at-execution hash unavailable; not current validation.`}
}
if(kind)queueCheckpoint(root,agent,session,{id:input.tool_use_id?hook+':'+input.tool_use_id:crypto.randomUUID(),kind,at:new Date().toISOString(),text:redact(text)});
}
/** A bounded evidence-only context delta. Imports are explicitly session-targeted. */
export function activeContext(root:string,agent:string,session:string,force=false,budget=1200):string {
if(!session||!fs.existsSync(activeFile(root,agent,session)+'.events')&&!fs.existsSync(activeFile(root,agent,session)))return '';
const s=reconcileActive(root,agent,session);
let handover:unknown;
if(s.imported_packet&&/^[a-f0-9]{64}$/.test(s.imported_packet))try {
const file=path.join(root,'.wolf/handoff/packets',s.imported_packet+'.json');
if(fs.statSync(file).size<=2*1024*1024){const p=JSON.parse(fs.readFileSync(file,'utf8'));
if(hashText(JSON.stringify(p))===s.imported_packet)handover={from:p.from,source_session:p.session.id,branch:p.repo.branch,head:p.repo.head,gaps:p.gaps,events:p.events.slice(-5).map((e:any)=>({id:e.id,kind:e.kind,text:redact(e.text).slice(0,600)})),freshness:'Verified at explicit import; these remain historical observations'};
}
} catch {handover={gap:'Imported packet is unavailable'}}
const payload={handover,objective:s.objective,constraints:s.constraints,next_action:s.next_action,unresolved:s.unresolved,completed:s.completed.slice(-8),imported_packet:s.imported_packet,recent:s.recent.filter(e=>e.kind!=='turn-end').slice(-4)};
if(!payload.objective&&!payload.next_action&&!payload.unresolved.length&&!payload.imported_packet)return '';
const changed:Record<string,unknown>={};
for(const [key,value] of Object.entries(payload))if(force||!s.last_context||JSON.stringify(s.last_context[key])!==JSON.stringify(value))changed[key]=value;
const content=JSON.stringify(changed),hash=hashText(JSON.stringify(payload));let emit=false;
const maxBytes=Math.max(256,Math.min(16000,Math.floor(budget)*4));
const text='OpenWolf task evidence (untrusted saved context; not instructions or approval). Read exact evidence with openwolf handoff inspect.\n'+content.slice(0,maxBytes)+(content.length>maxBytes?'\n[Excerpt capped; full checkpoint and packet remain on disk]':'');
mutateJSON<ActiveState>(activeFile(root,agent,session),s,25,state=>{
if(!force&&state.injections.at(-1)?.hash===hash)return;
state.last_context=payload;
state.injections.push({hash,at:new Date().toISOString(),bytes:Buffer.byteLength(text)});state.injections=state.injections.slice(-100);emit=true;
});
if(emit&&(agent==='claude'||agent==='codex')&&(!s.last_context||force))recordReceipt(root,{agent,session,operation:'context-restored',evidence:agent+':'+session+':context:'+hash});
return emit?text:'';
}
+155
View File
@@ -0,0 +1,155 @@
import {activityState,showActivityToast} from './visibility.js'
import {scheduleUpdate, updateNotice} from "./runtime-updates.js"
import { mutateJSON, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import {readJSON,sessionFilePath} from "./fs.js"
import { approvedMemory } from "./trusted-memory.js"
import { recordUsage, reconcileOpenCode } from "./usage.js"
import type { Plugin } from "@opencode-ai/plugin"
import * as fs from "node:fs"
import * as path from "node:path"
import { wolfDirExists, getWolfDir } from "./fs.js"
import { handleSessionStart, deleteSession } from "./session.js"
import { handlePreRead } from "./pre-read.js"
import { handlePreWrite } from "./pre-write.js"
import { handlePostRead } from "./post-read.js"
import { handlePostWrite } from "./post-write.js"
import { handleStop } from "./stop.js"
/**
* OpenCode event payloads have carried the session id in different places
* across versions: top-level `session_id`/`sessionID` in older builds, and
* nested under `properties` (`properties.info.id` for session.created,
* `properties.sessionID` elsewhere) in newer ones. Accept all shapes.
*/
function extractSessionId(source: unknown): string {
if (!source || typeof source !== "object") return ""
const obj = source as {
session_id?: unknown
sessionID?: unknown
properties?: { info?: { id?: unknown }; sessionID?: unknown }
}
const candidates = [
obj.session_id,
obj.sessionID,
obj.properties?.info?.id,
obj.properties?.sessionID,
]
for (const c of candidates) {
if (typeof c === "string" && c) return c
}
return ""
}
export const OpenWolf: Plugin = async ({ directory, client }) => {
let reconciliation: Promise<void> = Promise.resolve()
const refreshUsage = (id?: string) => {
reconciliation = reconciliation.then(() => reconcileOpenCode(directory,client,id)).catch(error => console.warn(`OpenWolf usage reconciliation: ${error}`))
return reconciliation
}
if (wolfDirExists(directory)) void refreshUsage()
return {
event: async ({ event }: { event: { type: string; [key: string]: unknown } }) => {
if (event.type === "session.created" && !wolfDirExists(directory)) return
if (!wolfDirExists(directory)) return
if (event.type === "message.updated") {
try {recordUsage(directory, (event as any).properties?.info)} catch(error) {console.warn(String(error))}
}
const sessionId = extractSessionId(event)
if (!sessionId) return
if (event.type === "session.created") {
handleSessionStart(directory, sessionId)
scheduleUpdate(directory)
const notice=updateNotice(directory,sessionId)
if(notice)showActivityToast(client,notice)
}
if (event.type === "session.idle" || event.type === "session.error" || event.type === "session.deleted") {
scheduleUpdate(directory)
const update=updateNotice(directory,sessionId)
const activity=update??activityState(directory,{agent:"opencode",session:sessionId,turn:String(readJSON<Record<string,unknown>>(sessionFilePath(path.join(getWolfDir(directory),"hooks"),sessionId),{}).stop_count??0),surface:"opencode-toast"}).message
if(activity)showActivityToast(client,activity)
await refreshUsage(sessionId)
handleStop(directory, sessionId)
}
if (event.type === "session.deleted") {
const file=sessionFilePath(path.join(getWolfDir(directory),"hooks"),sessionId)
mutateJSON<Record<string,unknown>>(file,{},HOOK_LOCK_BUDGET_MS,state=>{state.ended=new Date().toISOString()})
deleteSession(sessionId)
}
},
"tool.execute.before": async (input: { tool: string; sessionID: string }, output: { args: Record<string, unknown> }) => {
if (!wolfDirExists(directory)) return
const sessionId = extractSessionId(input)
if (!sessionId) return
const args: Record<string, unknown> = output.args || {}
const tool = input.tool.toLowerCase()
if (tool === "read") {
const filePath = String(args.filePath || args.file_path || "")
const isRangedRead = args.offset !== undefined || args.limit !== undefined
if (filePath) handlePreRead(directory, sessionId, filePath, isRangedRead)
}
if (tool === "write" || tool === "edit") {
const filePath = String(args.filePath || args.file_path || "")
const content = String(args.content || "")
const oldStr = String(args.old_string || args.oldString || "")
const newStr = String(args.new_string || args.newString || "")
if (filePath) handlePreWrite(directory, sessionId, filePath, content, oldStr, newStr)
}
},
"tool.execute.after": async (input: { tool: string; sessionID: string; args: Record<string, unknown>; callID?: string }, output: Record<string, unknown>) => {
if (!wolfDirExists(directory)) return
const sessionId = extractSessionId(input)
if (!sessionId) return
const tool = input.tool.toLowerCase()
const args = input.args || {}
if (tool === "read") {
const filePath = String(args.filePath || args.file_path || "")
const content = String((output as any).output || "")
if (filePath) handlePostRead(directory, sessionId, filePath, content, args.offset !== undefined || args.limit !== undefined, input.callID)
}
if (tool === "write" || tool === "edit") {
const filePath = String(args.filePath || args.file_path || "")
const content = String(args.content || "")
const oldStr = String(args.old_string || args.oldString || "")
const newStr = String(args.new_string || args.newString || "")
if (filePath) handlePostWrite(directory, sessionId, input.tool, filePath, content, oldStr, newStr)
}
},
stop: async (input: Record<string, unknown>) => {
if (!wolfDirExists(directory)) return
const sessionId = extractSessionId(input)
if (!sessionId) return
handleStop(directory, sessionId)
},
"experimental.chat.system.transform": async (_input: Record<string, unknown>, output: { system: string[] }) => {
if (!wolfDirExists(directory)) return
const wolfDir = getWolfDir(directory)
const openwolfPath = path.join(wolfDir, "OPENWOLF.md")
if (fs.existsSync(openwolfPath)) {
try {
const openwolfContent = approvedMemory(wolfDir, "OPENWOLF.md")
output.system.push(`\n<openwolf-protocol>\n${openwolfContent}\n</openwolf-protocol>`)
} catch {}
}
},
}
}
@@ -0,0 +1,16 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {execFileSync} from 'node:child_process';
/** Only durable bug knowledge is shared. Anatomy, usage, sessions and STATUS
* always remain in the worktree's own .wolf directory.
*/
export function sharedWolfDir(wolfDir: string): string {
const root=path.dirname(wolfDir);
try {
if (!fs.statSync(path.join(root,'.git')).isFile()) return wolfDir;
const common=execFileSync('git',['rev-parse','--path-format=absolute','--git-common-dir'],{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore'],timeout:1000}).trim();
if (path.basename(common) !== '.git') return wolfDir;
const shared=path.join(path.dirname(common),'.wolf');
return fs.statSync(shared).isDirectory() ? shared : wolfDir;
} catch {return wolfDir;}
}
+369
View File
@@ -0,0 +1,369 @@
import type { RealUsage } from "./shared.js";
// ─────────────────────────────────────────────────────────────────────────────
// Pure token-ledger math: types, folds, lifetime derivation, migrations.
// Deliberately free of value imports so tests (and any consumer) can load it
// straight from source under Node's type stripping. IO and session assembly
// live in ledger.ts.
// ─────────────────────────────────────────────────────────────────────────────
export const MAX_LEDGER_SESSIONS = 200;
export interface SessionFileRead {
count: number;
tokens: number;
first_read: string;
read_mtime?: number;
anatomy_hit?: boolean;
}
export interface SessionData {
agent?: string;
session_id: string;
started: string;
files_read: Record<string, SessionFileRead>;
files_written: Array<{ file: string; action: string; tokens: number; at: string }>;
edit_counts: Record<string, number>;
anatomy_hits: number;
anatomy_misses: number;
repeated_reads_warned: number;
reads_denied?: number;
denied_tokens_saved?: number;
stop_count: number;
reminders_sent: Record<string, number>;
pending_reminders?: string[];
injected_tokens_estimated?: number;
injected_by_source?: Record<string, number>;
bash_governed?: Array<{ family: string; action: string; original_tokens: number; entered_tokens: number; at: string }>;
[key: string]: unknown;
}
export interface FamilyTotals {
calls: number;
original_tokens: number;
entered_tokens: number;
}
/** Sum of two per-key maps, used for both family and per-model rollups. */
export function foldMap<T extends object>(
target: Record<string, T>,
source: Record<string, T> | undefined
): void {
for (const [key, value] of Object.entries(source ?? {})) {
if (!value || typeof value !== "object") continue;
const bucket = (target[key] ??= {} as T);
for (const [field, n] of Object.entries(value)) {
if (typeof n !== "number" || !isFinite(n)) continue;
(bucket as Record<string, number>)[field] = ((bucket as Record<string, number>)[field] ?? 0) + n;
}
}
}
export interface SessionEntry {
id: string;
agent: string;
started: string;
ended: string;
reads: Array<{
file: string;
tokens_estimated: number;
was_repeated: boolean;
anatomy_had_description: boolean;
}>;
writes: Array<{ file: string; tokens_estimated: number; action: string }>;
totals: {
input_tokens_estimated: number;
output_tokens_estimated: number;
reads_count: number;
writes_count: number;
repeated_reads_blocked: number;
repeated_reads_warned?: number;
anatomy_lookups: number;
anatomy_misses?: number;
savings_estimated?: number;
/** Tokens OpenWolf itself injected into context (digests, hints, warnings). */
injection_tokens_estimated?: number;
/** Bash governor (2.3): original size of governed outputs vs what entered. */
bash_governed_original_tokens?: number;
bash_governed_entered_tokens?: number;
bash_governed_calls?: number;
/** Per-command-family governor breakdown (2.5). The scalars above cannot
* answer "which families are actually paying off" — the classifier
* already knows the family at the rewrite point, so keep it. */
bash_governed_by_family?: Record<string, FamilyTotals>;
/** Tokens the agent spent reading .wolf/ state itself (2.4). */
wolf_internal_tokens?: number;
};
injected_by_source?: Record<string, number>;
real_usage?: RealUsage;
/** Transcript-verified hook activity (2.2). Absent = verification
* unavailable (old transcript, format drift, or non-Claude agent); the
* self-reported totals are then estimates, not facts. */
verified?: {
hooks_fired: number;
hooks_failed: number;
injections_delivered: number;
injection_tokens_delivered: number;
per_hook: Record<string, { fired: number; failed: number; last_exit: number }>;
last_failure?: { hook: string; stderr_head: string };
};
}
export interface LifetimeTotals {
total_tokens_estimated: number;
total_reads: number;
total_writes: number;
total_sessions: number;
anatomy_hits: number;
anatomy_misses: number;
repeated_reads_blocked: number;
repeated_reads_warned: number;
estimated_savings_vs_bare_cli: number;
injection_tokens_estimated: number;
[key: string]: number;
}
export interface LifetimeMaps {
/** Governor deltas per command family, lifetime. */
bash_governed_by_family: Record<string, FamilyTotals>;
/** Measured usage per model id, lifetime. Drives the cost figure. */
real_by_model: Record<string, ModelUsage>;
}
export interface ModelUsage {
input_tokens: number;
output_tokens: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
api_calls: number;
}
export interface LedgerData {
version: number;
created_at: string;
lifetime: LifetimeTotals;
/** Totals folded out of sessions that were rolled off the retained window. */
lifetime_baseline?: Partial<LifetimeTotals>;
/** Lifetime per-key rollups. Kept out of LifetimeTotals, whose index
* signature is number, and out of lifetime_baseline, which numericFields()
* strips down to scalars on every trim. */
lifetime_maps?: LifetimeMaps;
lifetime_baseline_maps?: LifetimeMaps;
sessions: SessionEntry[];
[key: string]: unknown;
}
export function emptyLedger(): LedgerData {
return {
version: 1,
created_at: "",
lifetime: {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
total_sessions: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
},
sessions: [],
daemon_usage: [],
waste_flags: [],
optimization_report: { last_generated: null, patterns: [] },
};
}
/** The totals block of a session entry, derived from live session state. */
export function buildSessionTotals(
session: SessionData,
reads: SessionEntry["reads"],
writes: SessionEntry["writes"]
): SessionEntry["totals"] {
return {
input_tokens_estimated: reads.reduce((sum, r) => sum + r.tokens_estimated, 0),
output_tokens_estimated: writes.reduce((sum, w) => sum + w.tokens_estimated, 0),
reads_count: reads.length,
writes_count: writes.length,
// Honest accounting: only reads the hook actually denied count as
// blocked (warnings do not prevent the read from happening).
repeated_reads_blocked: session.reads_denied ?? 0,
repeated_reads_warned: session.repeated_reads_warned ?? 0,
anatomy_lookups: session.anatomy_hits,
anatomy_misses: session.anatomy_misses,
// Honest savings: tokens of reads that were denied, nothing else.
savings_estimated: session.denied_tokens_saved ?? 0,
// The other side of the scale: what OpenWolf's own context injection cost.
injection_tokens_estimated: session.injected_tokens_estimated ?? 0,
// Bash governor deltas: original-vs-entered is measured at the rewrite
// point, which nothing else in the ecosystem can observe.
bash_governed_calls: (session.bash_governed ?? []).length || undefined,
bash_governed_original_tokens: sumBy(session.bash_governed, (g) => g.original_tokens),
bash_governed_entered_tokens: sumBy(session.bash_governed, (g) => g.entered_tokens),
bash_governed_by_family: byFamily(session.bash_governed),
wolf_internal_tokens: typeof session.wolf_internal_tokens === "number" ? session.wolf_internal_tokens : undefined,
};
}
/**
* Group the session's governed calls by command family. The session file keeps
* only the last 200 records, so this is computed at flush time while they are
* still there; the ledger then carries the rollup forever.
*/
function byFamily(
governed: SessionData["bash_governed"]
): Record<string, FamilyTotals> | undefined {
if (!governed || governed.length === 0) return undefined;
const out: Record<string, FamilyTotals> = {};
for (const g of governed) {
const key = typeof g.family === "string" && g.family ? g.family : "other";
const bucket = (out[key] ??= { calls: 0, original_tokens: 0, entered_tokens: 0 });
bucket.calls++;
if (isFinite(g.original_tokens)) bucket.original_tokens += g.original_tokens;
if (isFinite(g.entered_tokens)) bucket.entered_tokens += g.entered_tokens;
}
return out;
}
function sumBy<T>(arr: T[] | undefined, fn: (item: T) => number): number | undefined {
if (!arr || arr.length === 0) return undefined;
return arr.reduce((sum, item) => sum + fn(item), 0);
}
/**
* Position-weighted context cost (2.2). Fresh input is ~0.004% of a session's
* input-side tokens; the real cost of a byte is that it sits in the cached
* prefix and is re-read at the cache-read rate on EVERY subsequent API call:
* cost = tokens x (total_calls - call_index) x rate_per_token
* A 10k-token read at call 500 of 1,458 costs ~$2.87 (Sonnet-class rates);
* the same read at call 1,450 costs ~$0.02. Waste rankings must use this,
* not raw token counts.
*/
export function positionWeightedCostUsd(
tokens: number,
callIndex: number,
totalCalls: number,
cacheReadUsdPerMTok: number
): number {
const remaining = Math.max(0, totalCalls - callIndex);
return (tokens * remaining * cacheReadUsdPerMTok) / 1_000_000;
}
export function addInto(target: Record<string, number>, key: string, value: number | undefined): void {
if (typeof value !== "number" || !isFinite(value)) return;
target[key] = (target[key] ?? 0) + value;
}
/** Copy only real numeric fields out of a possibly-partial totals object. */
export function numericFields(source: Record<string, number | undefined> | undefined): Record<string, number> {
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(source ?? {})) {
if (typeof v === "number" && isFinite(v)) out[k] = v;
}
return out;
}
/**
* Fold one session's per-key rollups into a LifetimeMaps accumulator. Kept
* separate from foldEntry because those maps cannot live in the scalar
* accumulator that numericFields() produces.
*/
export function emptyMaps(): LifetimeMaps {
return { bash_governed_by_family: {}, real_by_model: {} };
}
export function foldEntryMaps(acc: LifetimeMaps, e: SessionEntry): void {
foldMap(acc.bash_governed_by_family, e.totals?.bash_governed_by_family);
foldMap(acc.real_by_model, e.real_usage?.per_model as Record<string, ModelUsage> | undefined);
}
export function foldEntry(acc: Record<string, number>, e: SessionEntry): void {
addInto(acc, "total_tokens_estimated", e.totals.input_tokens_estimated + e.totals.output_tokens_estimated);
addInto(acc, "total_reads", e.totals.reads_count);
addInto(acc, "total_writes", e.totals.writes_count);
addInto(acc, "anatomy_hits", e.totals.anatomy_lookups);
addInto(acc, "anatomy_misses", e.totals.anatomy_misses);
addInto(acc, "repeated_reads_blocked", e.totals.repeated_reads_blocked);
addInto(acc, "repeated_reads_warned", e.totals.repeated_reads_warned);
addInto(acc, "estimated_savings_vs_bare_cli", e.totals.savings_estimated);
addInto(acc, "injection_tokens_estimated", e.totals.injection_tokens_estimated);
addInto(acc, "bash_governed_calls", e.totals.bash_governed_calls);
addInto(acc, "bash_governed_original_tokens", e.totals.bash_governed_original_tokens);
addInto(acc, "bash_governed_entered_tokens", e.totals.bash_governed_entered_tokens);
addInto(acc, "wolf_internal_tokens", e.totals.wolf_internal_tokens);
if (e.real_usage) {
addInto(acc, "real_input_tokens", e.real_usage.input_tokens);
addInto(acc, "real_output_tokens", e.real_usage.output_tokens);
addInto(acc, "real_cache_read_tokens", e.real_usage.cache_read_input_tokens);
addInto(acc, "real_cache_creation_tokens", e.real_usage.cache_creation_input_tokens);
addInto(acc, "real_api_calls", e.real_usage.api_calls);
}
}
/**
* Derive lifetime = baseline + fold(sessions). total_sessions is intentionally
* NOT derived here — session-start counts it once per new session.
*/
export function recomputeLifetime(ledger: LedgerData): void {
const acc = numericFields(ledger.lifetime_baseline);
delete acc.total_sessions;
const maps = emptyMaps();
foldMap(maps.bash_governed_by_family, ledger.lifetime_baseline_maps?.bash_governed_by_family);
foldMap(maps.real_by_model, ledger.lifetime_baseline_maps?.real_by_model);
for (const e of ledger.sessions) {
foldEntry(acc, e);
foldEntryMaps(maps, e);
}
ledger.lifetime_maps = maps;
const totalSessions = ledger.lifetime?.total_sessions ?? 0;
ledger.lifetime = {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
...acc,
total_sessions: totalSessions,
} as LifetimeTotals;
}
/**
* One-time legacy migration: sessions written before 2.0.5 stored the number
* of duplicate-read WARNINGS in totals.repeated_reads_blocked (the field
* predates deny mode). Under the current semantics a blocked read always
* credits savings_estimated > 0 (a denial saves the previous read's tokens,
* and denyEligible requires tokens > 0), so blocked > 0 with zero savings can
* only be legacy data. Move those counts to repeated_reads_warned and zero
* the blocked field. Also migrates the lifetime_baseline, which may hold
* folded-off legacy sessions. Returns the number of records rewritten.
* Caller is responsible for recomputeLifetime() and persisting.
*/
export function migrateLegacyBlockedCounts(ledger: LedgerData): number {
let migrated = 0;
for (const s of ledger.sessions ?? []) {
const t = s?.totals;
if (!t) continue;
if ((t.repeated_reads_blocked ?? 0) > 0 && !((t.savings_estimated ?? 0) > 0)) {
t.repeated_reads_warned = (t.repeated_reads_warned ?? 0) + t.repeated_reads_blocked;
t.repeated_reads_blocked = 0;
migrated++;
}
}
const base = ledger.lifetime_baseline;
if (
base &&
(base.repeated_reads_blocked ?? 0) > 0 &&
!((base.estimated_savings_vs_bare_cli ?? 0) > 0)
) {
base.repeated_reads_warned = (base.repeated_reads_warned ?? 0) + (base.repeated_reads_blocked ?? 0);
base.repeated_reads_blocked = 0;
migrated++;
}
return migrated;
}
+133
View File
@@ -0,0 +1,133 @@
import * as fs from "node:fs";
import * as crypto from "node:crypto";
import * as path from "node:path";
import { readJSON, writeJSON, readTranscriptUsage, detectAgent } from "./shared.js";
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js";
import {
MAX_LEDGER_SESSIONS,
emptyLedger,
buildSessionTotals,
numericFields,
foldEntry,
foldEntryMaps,
emptyMaps,
recomputeLifetime,
type SessionData,
type SessionEntry,
type LedgerData,
} from "./ledger-math.js";
// ─────────────────────────────────────────────────────────────────────────────
// Token-ledger writer shared by the Stop and SessionEnd hooks.
//
// Stop fires at the end of EVERY turn, so the ledger write must be idempotent:
// the session entry is UPSERTED by session id (replaced, never appended), and
// lifetime totals are derived — archived baseline + fold over the retained
// sessions — rather than incremented. The old increment-on-every-Stop scheme
// re-added turns 1..N on turn N, quadratically inflating every lifetime metric
// and duplicating session entries.
//
// The pure math (folds, lifetime derivation, migrations) lives in
// ledger-math.ts; this module owns IO and session-entry assembly.
// ─────────────────────────────────────────────────────────────────────────────
export * from "./ledger-math.js";
/** Build the ledger entry for the current session state (idempotent). */
export function buildSessionEntry(session: SessionData, transcriptPath?: string): SessionEntry {
const reads = Object.entries(session.files_read).map(([file, data]) => ({
file,
tokens_estimated: data.tokens,
was_repeated: data.count > 1,
anatomy_had_description: data.anatomy_hit === true,
}));
const writes = session.files_written.map((w) => ({
file: w.file,
tokens_estimated: w.tokens,
action: w.action,
}));
const entry: SessionEntry = {
id: session.session_id,
agent: typeof session.agent === "string" ? session.agent : detectAgent(),
started: session.started,
ended: new Date().toISOString(),
reads,
writes,
totals: buildSessionTotals(session, reads, writes),
};
if (session.injected_by_source && Object.keys(session.injected_by_source).length > 0) {
entry.injected_by_source = { ...session.injected_by_source };
}
if (transcriptPath) {
// readTranscriptUsage dedupes by message id, so this is the cumulative
// total for the whole session — correct to REPLACE, never to add.
const real = readTranscriptUsage(transcriptPath);
if (real) entry.real_usage = real;
}
return entry;
}
/** Upsert the entry, roll old sessions into the baseline, derive lifetime. */
export function flushSessionToLedger(wolfDir: string, entry: SessionEntry): void {
if (!entry.id) return;
const pending = path.join(wolfDir,"ledger-pending");
fs.mkdirSync(pending,{recursive:true});
const file = path.join(pending,crypto.randomUUID()+".json");
const fd = fs.openSync(file,"wx",0o600);
try { fs.writeFileSync(fd,JSON.stringify(entry)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
reconcileLedger(wolfDir);
}
/** All per-session snapshots survive the 200-row UI window. A late resumed
* session replaces its old snapshot instead of being added to baseline twice.
*/
export function reconcileLedger(wolfDir: string): boolean {
const ledgerPath = path.join(wolfDir,"token-ledger.json");
const pending = path.join(wolfDir,"ledger-pending");
const snapshots = path.join(wolfDir,"ledger-sessions");
fs.mkdirSync(snapshots,{recursive:true});
const result = withFileLock(ledgerPath+".lock",HOOK_LOCK_BUDGET_MS,()=>{
const read = <T>(file: string, fallback: T): T => {
try {return JSON.parse(fs.readFileSync(file,"utf8"));} catch(e) {if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e;}
};
const ledger = read<LedgerData>(ledgerPath,emptyLedger());
const baselineFile = path.join(wolfDir,"_legacy-ledger-baseline.json");
const legacy = read(baselineFile,{totals:ledger.lifetime_baseline ?? {},maps:ledger.lifetime_baseline_maps ?? emptyMaps()});
if (!fs.existsSync(baselineFile)) writeJSON(baselineFile,legacy);
const key = (e: SessionEntry) => crypto.createHash("sha256").update(`${e.agent ?? 'claude'}:${e.id}`).digest("hex")+".json";
for (const e of ledger.sessions ?? []) {
if (!e?.id) continue;
const dest = path.join(snapshots,key(e));
if (!fs.existsSync(dest)) writeJSON(dest,e);
}
let names: string[] = [];
try {names=fs.readdirSync(pending).filter(n=>n.endsWith(".json"));} catch {}
for (const name of names) {
const e = read<SessionEntry | null>(path.join(pending,name),null);
if (!e?.id || !e.totals) throw new Error("Invalid pending ledger entry retained for review");
const dest = path.join(snapshots,key(e));
const previous = read<SessionEntry | null>(dest,null);
if (!previous || e.ended >= previous.ended) writeJSON(dest,e);
}
const all = fs.readdirSync(snapshots).filter(n=>n.endsWith(".json")).map(n=>read<SessionEntry>(path.join(snapshots,n),null as any))
.sort((a,b)=>a.started.localeCompare(b.started) || a.id.localeCompare(b.id));
ledger.sessions = all.slice(-MAX_LEDGER_SESSIONS);
const baseline = numericFields(legacy.totals);
ledger.lifetime_baseline = baseline;
ledger.lifetime_baseline_maps = structuredClone(legacy.maps);
for (const old of all.slice(0,Math.max(0,all.length-MAX_LEDGER_SESSIONS))) {
foldEntry(baseline,old);
foldEntryMaps(ledger.lifetime_baseline_maps,old);
}
recomputeLifetime(ledger);
writeJSON(ledgerPath,ledger);
for (const name of names) {try {fs.unlinkSync(path.join(pending,name));} catch {}}
return true;
});
return result === true;
}
+41
View File
@@ -0,0 +1,41 @@
import { persistRead, reconcileReads } from "./event-journal.js"
import * as crypto from "node:crypto"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, normalizePath, estimateTokens, sessionFilePath } from "./fs.js"
import { lookupEntry } from "./anatomy.js"
import type { PartialSessionState } from "./types.js"
export function handlePostRead(directory: string, sessionId: string, filePath: string, content: string, ranged = false, observationId?: string): void {
if (ranged) return
filePath=path.resolve(directory,filePath)
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const normalizedFile = normalizePath(filePath)
const projectDir = normalizePath(directory)
const rel=path.relative(directory,filePath)
if (!rel || rel==='..' || rel.startsWith('..'+path.sep) || path.isAbsolute(rel) || rel.split(path.sep)[0]==='.wolf') return
const ext = path.extname(filePath).toLowerCase()
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".css", ".json", ".yaml", ".yml"])
const proseExts = new Set([".md", ".txt", ".rst"])
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed"
let tokens = content ? estimateTokens(content, type as "code" | "prose" | "mixed") : 0
// Fallback: if the tool output had no content, use the anatomy token estimate
if (tokens === 0) {
const entry = lookupEntry(wolfDir, projectDir, normalizedFile)
if (entry) tokens = entry.tokens
}
let fingerprint: string | undefined
let mtime=0
try {const current=fs.readFileSync(filePath,"utf8");if(current===content){fingerprint=crypto.createHash("sha256").update(current).digest("hex");mtime=fs.statSync(filePath).mtimeMs}}catch{}
persistRead(sessionFile,{id:observationId ?? crypto.randomUUID(),file:normalizedFile,at:new Date().toISOString(),tokens,fingerprint,mtime})
reconcileReads(sessionFile)
}
+261
View File
@@ -0,0 +1,261 @@
import { persistObservation, reconcileReads } from "./event-journal.js"
import * as crypto from "node:crypto"
import { recordBug } from "./bug-journal.js"
import { sharedWolfDir } from "./knowledge-root.js";
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import { nextBugId } from "./bug-id.js"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, normalizePath, estimateTokens, isSensitiveFile, sessionFilePath } from "./fs.js"
import { extractDescription, withAnatomyLock, loadStoreReconciled, saveStore, renderToFile, sha256, LOCK_BUDGET_MS } from "./anatomy.js"
import type { PartialSessionState, FixDetection } from "./types.js"
// File types where a value/string change is normal content editing, not a bug
// fix — auto bug detection never runs on these (see autoDetectBugFix). Without
// this, a version bump in a README or a key change in a JSON/YAML config is
// logged as a "wrong-value" bug, since the detector matches quoted spans
// (including markdown backticks) regardless of file type.
const NON_CODE_EXTS = new Set([
".md", ".mdx", ".markdown", ".txt", ".rst", ".adoc",
".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".env",
".lock", ".csv", ".tsv",
])
export function handlePostWrite(
directory: string,
sessionId: string,
toolName: string,
filePath: string,
content: string,
oldStr: string,
newStr: string
): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const projectRoot = directory
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath)
const relPath = normalizePath(path.relative(projectRoot, absolutePath))
if (relPath.startsWith(".wolf/")) return
// Never track files outside the project root (e.g. a scratchpad under
// /private/tmp). path.relative() yields ../.. section keys that pollute
// anatomy.md and are wiped again by every full scan, so the index churns
// instead of converging.
if (relPath.startsWith("..") || path.isAbsolute(relPath)) return
// Never track secret-bearing files in anatomy/memory (issue #54): .env is
// not the only file whose description would leak sensitive content.
const baseName = path.basename(absolutePath)
if (isSensitiveFile(baseName)) return
updateAnatomy(wolfDir, absolutePath, projectRoot, content)
appendToMemory(wolfDir, toolName, absolutePath, projectRoot, content, newStr)
trackSession(sessionFile, filePath, toolName, content, newStr, baseName, projectRoot, absolutePath)
if (oldStr && newStr) {
autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr)
}
}
function updateAnatomy(wolfDir: string, absolutePath: string, projectRoot: string, content: string): void {
try {
const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath))
let fileContent = ""
try {
fileContent = fs.readFileSync(absolutePath, "utf-8")
} catch {
fileContent = content ?? ""
}
const desc = extractDescription(absolutePath).slice(0, 100)
const ext = path.extname(absolutePath).toLowerCase()
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"])
const proseExts = new Set([".md", ".txt", ".rst"])
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed"
const tokens = estimateTokens(fileContent, type as "code" | "prose" | "mixed")
let size: number | undefined
let mtimeMs: number | undefined
try {
const st = fs.statSync(absolutePath)
size = st.size
mtimeMs = st.mtimeMs
} catch {}
withAnatomyLock(wolfDir, LOCK_BUDGET_MS, () => {
const store = loadStoreReconciled(wolfDir, projectRoot)
store.files[relPathLocal] = {
description: desc,
tokens,
hash: sha256(fileContent).slice(0, 16),
size,
mtimeMs,
updatedAt: new Date().toISOString(),
source: "hook",
// The plugin cannot recompute symbols; drop them so stale line
// ranges never misdirect a slice read (the next scan restores them).
symbols: undefined,
}
store.meta.lastScanned = new Date().toISOString()
renderToFile(wolfDir, store)
saveStore(wolfDir, store)
})
} catch {}
}
function appendToMemory(
wolfDir: string,
toolName: string,
absolutePath: string,
projectRoot: string,
content: string,
newStr: string
): void {
try {
const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited"
const relFile = normalizePath(path.relative(projectRoot, absolutePath))
const fileContent = content ?? ""
const ext = path.extname(absolutePath).toLowerCase()
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"])
const type = codeExts.has(ext) ? "code" : "mixed"
const writeTokens = estimateTokens(fileContent || newStr, type as "code" | "prose" | "mixed")
let changeDesc = ""
if (content && newStr) {
changeDesc = summarizeEdit(content, newStr, path.basename(absolutePath))
}
const memoryPath = path.join(wolfDir, "memory.md")
const outcome = changeDesc || "—"
appendMarkdown(memoryPath, `| ${timeShort()} | ${action} ${relFile} | ${outcome} | ~${writeTokens} |\n`)
} catch {}
}
function trackSession(
sessionFile: string,
filePath: string,
toolName: string,
content: string,
newStr: string,
baseName: string,
projectRoot: string,
absolutePath: string
): void {
try {
const normalizedFile=normalizePath(absolutePath)
const action=toolName.toLowerCase()==="write"?"create":"edit"
const tokens=estimateTokens(content || newStr,"code")
const editKey=normalizePath(path.relative(projectRoot,absolutePath))
persistObservation(sessionFile,{id:crypto.randomUUID(),kind:"write",file:normalizedFile,action,tokens,editKey,at:new Date().toISOString()})
reconcileReads(sessionFile)
} catch {}
}
export function summarizeEdit(oldStr: string, newStr: string, filename: string): string {
const oldLines = oldStr.split("\n")
const newLines = newStr.split("\n")
const oldCount = oldLines.length
const newCount = newLines.length
if (newStr.includes("try") && newStr.includes("catch") && !oldStr.includes("catch")) return "added error handling"
if (newStr.includes("?.") && !oldStr.includes("?.")) return "added optional chaining"
if (newStr.includes("?? ") && !oldStr.includes("?? ")) return "added nullish coalescing"
if (!newStr.trim() || newStr.trim().length < oldStr.trim().length * 0.2) return `removed ${oldCount} lines`
const oldImports = oldLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length
const newImports = newLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length
if (newImports > oldImports && Math.abs(newCount - oldCount) <= newImports - oldImports + 1) return `added ${newImports - oldImports} import(s)`
if (oldCount === 1 && newCount === 1) {
const o = oldStr.trim()
const n = newStr.trim()
const oStr = o.match(/['"`]([^'"`]+)['"`]/)
const nStr = n.match(/['"`]([^'"`]+)['"`]/)
if (oStr && nStr && oStr[1] !== nStr[1]) return `"${oStr[1].slice(0, 25)}" → "${nStr[1].slice(0, 25)}"`
return "inline fix"
}
const fnMatch = newStr.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/)
if (fnMatch) return `modified ${fnMatch[1]}()`
if (newCount > oldCount + 5) return `expanded (+${newCount - oldCount} lines)`
if (oldCount > newCount + 5) return `reduced (-${oldCount - newCount} lines)`
return `${oldCount}→${newCount} lines`
}
function bugAutoDetectEnabled(wolfDir: string): boolean {
try {
const cfg = readJSON<{ openwolf?: { buglog?: { auto_detect?: boolean } } }>(
path.join(wolfDir, "config.json"),
{}
)
// Default on; only an explicit `false` disables auto bug detection.
return cfg.openwolf?.buglog?.auto_detect !== false
} catch {
return true
}
}
export function autoDetectBugFix(wolfDir: string, absolutePath: string, projectRoot: string, oldStr: string, newStr: string): void {
const basename = path.basename(absolutePath)
const ext = path.extname(basename).toLowerCase()
// Bug-fix detection is a code concept — never fire on prose/docs/data files.
if (NON_CODE_EXTS.has(ext)) return
// Respect an explicit opt-out in .wolf/config.json (default: enabled).
if (!bugAutoDetectEnabled(wolfDir)) return
const detection=detectFixPattern(oldStr,newStr,ext,basename)
if (!detection) return
recordBug(wolfDir,{error_message:detection.summary,file:normalizePath(path.relative(projectRoot,absolutePath)),root_cause:detection.rootCause,fix:detection.fix,tags:["auto-detected",detection.category,ext.slice(1)||"unknown"],status:"candidate",observed_worktree:projectRoot})
}
export function detectFixPattern(oldStr: string, newStr: string, ext: string, basename: string): FixDetection | null {
const oldLines = oldStr.split("\n")
const newLines = newStr.split("\n")
if (!/(?:Test|IT|Spec)\.\w+$|(?:[._](?:test|spec))\.\w+$|^test_/.test(basename) && /\bcatch\s*\(/.test(newStr) && !/\bcatch\s*\(/.test(oldStr)) {
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1] || basename
return { category: "error-handling", summary: `Missing error handling in ${fn}`, rootCause: "Code path had no error handling", fix: "Added try/catch block", context: extractChangedLines(oldStr, newStr) }
}
if ((newStr.includes("?.") && !oldStr.includes("?.")) || (newStr.includes("?? ") && !oldStr.includes("?? "))) {
return { category: "null-safety", summary: `Null/undefined access in ${basename}`, rootCause: "Property access on potentially null/undefined value", fix: "Added null safety", context: extractChangedLines(oldStr, newStr) }
}
if (/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(newStr) && !/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(oldStr)) {
const condition = newStr.match(/if\s*\(([^)]+)\)/)?.[1]?.trim().slice(0, 60) || "condition"
return { category: "guard-clause", summary: "Missing guard clause", rootCause: `No early return for: ${condition}`, fix: `Added guard clause: if (${condition.slice(0, 40)})` }
}
if (oldLines.length <= 3 && newLines.length <= 3) {
const oStrs = oldStr.trim().match(/['"`]([^'"`]{2,})['"`]/g) || []
const nStrs = newStr.trim().match(/['"`]([^'"`]{2,})['"`]/g) || []
if (oStrs.length > 0 && nStrs.length > 0) {
for (let i = 0; i < Math.min(oStrs.length, nStrs.length); i++) {
if (oStrs[i] !== nStrs[i]) {
return { category: "wrong-value", summary: "Incorrect value in code", rootCause: `Had ${oStrs[i].slice(0, 50)}`, fix: `Changed to ${nStrs[i].slice(0, 50)}` }
}
}
}
}
if (newStr.includes("await ") && !oldStr.includes("await ")) {
return { category: "async-fix", summary: "Missing await", rootCause: "Async call without await", fix: "Added await to async call", context: extractChangedLines(oldStr, newStr) }
}
return null
}
function extractChangedLines(oldStr: string, newStr: string): string {
const oldLines = new Set(oldStr.split("\n").map(l => l.trim()).filter(Boolean))
const added = newStr.split("\n").map(l => l.trim()).filter(l => l && !oldLines.has(l))
return added.slice(0, 2).map(l => l.slice(0, 60)).join("; ")
}
+100
View File
@@ -0,0 +1,100 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, normalizePath, sessionFilePath } from "./fs.js"
import { lookupEntry } from "./anatomy.js"
import type { PartialSessionState } from "./types.js"
export function handlePreRead(directory: string, sessionId: string, filePath: string, isRangedRead = false): void {
filePath=path.resolve(directory,filePath)
const relative=path.relative(directory,filePath)
if (!relative || relative === ".." || relative.startsWith(".."+path.sep) || path.isAbsolute(relative)) return
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const normalizedFile = normalizePath(filePath)
const projectDir = normalizePath(directory)
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: ""
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) return
// Ranged reads (offset/limit) are exactly what the symbol hints steer the
// model toward — never warn about them or record them as full reads (a
// ranged first contact must not make a later legitimate full read look like
// a duplicate).
if (isRangedRead) return
const session = readJSON<PartialSessionState>(sessionFile, {
session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0,
repeated_reads_warned: 0,
})
if (!session.files_read) session.files_read = {}
// Repeat detection is mtime-gated (issue #41): only warn while the file is
// unchanged since the recorded read. A modified file is a legitimate
// re-read — drop the stale record and track it fresh below.
if (session.files_read[normalizedFile]) {
const prev = session.files_read[normalizedFile]
let modifiedSinceRead = true
try {
const mtime = fs.statSync(filePath).mtimeMs
modifiedSinceRead = prev.read_mtime === undefined || mtime !== prev.read_mtime
} catch {}
if (!modifiedSinceRead) {
prev.count++
session.repeated_reads_warned = (session.repeated_reads_warned || 0) + 1
console.warn(`OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tok), unchanged since. If you only need the gist, your earlier read may suffice; for exact text (edit anchors, line numbers), the re-read is fine.`)
writeJSON(sessionFile, session)
return
}
delete session.files_read[normalizedFile]
}
// Anatomy lookup: O(1) against the durable store, legacy md scan fallback.
const entry = lookupEntry(wolfDir, projectDir, normalizedFile)
const found = entry !== null
if (entry) {
if (entry.description) {
console.warn(`OpenWolf anatomy: ${entry.file}: ${entry.description} (~${entry.tokens} tok)`)
}
// Symbol hint: point at slices of big files. Suppressed if the on-disk
// file no longer matches what was indexed — a stale line range that
// misdirects an offset read is worse than no hint at all.
if (entry.symbols && entry.symbols.length > 0) {
let fresh = false
try {
const st = fs.statSync(filePath)
fresh = (entry.size === undefined || st.size === entry.size) &&
(entry.mtimeMs === undefined || Math.abs(st.mtimeMs - entry.mtimeMs) < 1)
} catch {}
if (fresh) {
const top = [...entry.symbols].sort((a, b) => b.tokens - a.tokens).slice(0, 5)
const list = top.map((s) => `${s.kind} ${s.name} L${s.startLine}-${s.endLine} ~${s.tokens} tok`).join("; ")
console.warn(`Largest sections: ${list}. Read with offset/limit to fetch just the part you need.`)
}
}
}
session.anatomy_hits = (session.anatomy_hits || 0) + (found ? 1 : 0)
session.anatomy_misses = (session.anatomy_misses || 0) + (found ? 0 : 1)
// Record initial read entry (tokens will be updated in post-read)
let readMtime: number | undefined
try {
readMtime = fs.statSync(filePath).mtimeMs
} catch {}
session.files_read[normalizedFile] = {
count: 1,
tokens: 0,
first_read: new Date().toISOString(),
read_mtime: readMtime,
anatomy_hit: found,
}
writeJSON(sessionFile, session)
}
+110
View File
@@ -0,0 +1,110 @@
import {recordReceipt} from './visibility.js';
import { sharedWolfDir } from "./knowledge-root.js";
import { approvedMemory } from "./trusted-memory.js"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, readMarkdown, normalizePath, readJSON } from "./fs.js"
const STOP_WORDS = new Set([
"error", "function", "return", "const", "this", "that", "with", "from",
"import", "export", "class", "interface", "type", "undefined", "null",
"true", "false", "string", "number", "object", "array", "value",
"file", "path", "name", "data", "response", "request", "result",
"should", "must", "does", "have", "been", "will", "would", "could",
"when", "then", "else", "each", "some", "every", "only",
])
function tokenize(text: string): Set<string> {
return new Set(
text.replace(/[^\w\s]/g, " ").split(/\s+/)
.filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase()))
.map(w => w.toLowerCase())
)
}
export function handlePreWrite(directory: string, sessionId: string, filePath: string, content: string, oldStr: string, newStr: string): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const allContent = [content, oldStr, newStr].join("\n")
if (!allContent.trim()) return
checkCerebrum(wolfDir, allContent)
if (filePath && (oldStr || content)) {
checkBugLog(wolfDir, filePath, oldStr, newStr, content, sessionId)
}
}
function checkCerebrum(wolfDir: string, content: string): void {
const cerebrumContent = approvedMemory(wolfDir, "cerebrum.md")
const doNotRepeatSection = cerebrumContent.split("## Do-Not-Repeat")[1]
if (!doNotRepeatSection) return
const entries = doNotRepeatSection.split("## ")[0]
const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-"))
for (const line of lines) {
const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "")
if (!trimmed) continue
const patterns: string[] = []
const quotedMatches = trimmed.match(/"([^"]+)"/g) || trimmed.match(/'([^']+)'/g) || trimmed.match(/`([^`]+)`/g)
if (quotedMatches) {
for (const qm of quotedMatches) {
patterns.push(qm.replace(/["'`]/g, ""))
}
}
const neverMatch = trimmed.match(/(?:never use|avoid|don't use|do not use)\s+(\w+)/i)
if (neverMatch) patterns.push(neverMatch[1])
for (const pattern of patterns) {
try {
const regex = new RegExp(`\\b${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i")
if (regex.test(content)) {
console.warn(`⚠️ OpenWolf cerebrum warning: "${trimmed}" — check your code before proceeding.`)
}
} catch {}
}
}
}
interface BugEntry {
id: string
error_message: string
root_cause: string
fix: string
file: string
tags: string[]
}
function checkBugLog(wolfDir: string, filePath: string, oldStr: string, newStr: string, content: string, sessionId: string): void {
const bugLogPath = path.join(sharedWolfDir(wolfDir), "buglog.json")
if (!fs.existsSync(bugLogPath)) return
const bugLog = readJSON<{ version: number; bugs: BugEntry[] }>(bugLogPath, { version: 1, bugs: [] })
if (bugLog.bugs.length === 0) return
const basename = path.basename(filePath)
const fileMatches = bugLog.bugs.filter((b: BugEntry) => path.basename(b.file) === basename)
if (fileMatches.length === 0) return
const editText = (oldStr + " " + newStr + " " + content).toLowerCase()
const editTokens = tokenize(editText)
const relevant = fileMatches.filter((bug: BugEntry) => {
const tagHit = bug.tags.some((t: string) => editText.includes(t.toLowerCase()))
if (tagHit) return true
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause)
const overlap = [...editTokens].filter(t => bugTokens.has(t))
return overlap.length >= 3
})
if (relevant.length === 0) return
const fixes=relevant.slice(0,2).filter(b=>typeof b.fix==="string"&&b.fix.trim())
if(fixes.length)recordReceipt(path.dirname(wolfDir),{agent:"opencode",session:sessionId,operation:"fix-retrieved",evidence:JSON.stringify(fixes.map(b=>[b.id,b.fix])),count:fixes.length})
console.warn(`📋 OpenWolf buglog: ${relevant.length} past bug(s) found for ${basename} — review for context, do NOT apply blindly:`)
for (const bug of relevant.slice(0, 2)) {
console.warn(` [${bug.id}] "${bug.error_message.slice(0, 70)}"`)
console.warn(` Cause: ${bug.root_cause.slice(0, 80)}`)
console.warn(` Fix: ${bug.fix.slice(0, 80)}`)
}
}
@@ -0,0 +1,119 @@
import {visibilityMode} from './visibility.js';
/** Session-pinned npm runtimes. This module performs local I/O only. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {spawn} from 'node:child_process';
import {pathToFileURL, fileURLToPath} from 'node:url';
export const RUNTIME_PROTOCOL = 1;
export const CHECK_INTERVAL = 24 * 3600_000;
export interface UpdateState {
checkedAt?: number; latest?: string; selected?: string; previous?: string;
status?: 'current'|'ready'|'available'|'error'|'unsupported'; detail?: string;
}
export const stableVersion = (v: unknown): v is string => typeof v === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(v) && v.split('.').every(n=>Number.isSafeInteger(Number(n)));
export function newer(a: string,b: string): boolean {
if (!stableVersion(a)||!stableVersion(b)) return false;
const x=a.split('.').map(Number),y=b.split('.').map(Number);
for(let i=0;i<3;i++)if(x[i]!==y[i])return x[i]>y[i];
return false;
}
export function compatible(a:string,b:string):boolean {
if(!stableVersion(a)||!stableVersion(b))return false;
const x=a.split('.'),y=b.split('.');
return x[0]===y[0] && (x[0]!=='0'||x[1]===y[1]);
}
export function readObject(file:string):any {try{const v=JSON.parse(fs.readFileSync(file,'utf8'));return v&&typeof v==='object'&&!Array.isArray(v)?v:{}}catch{return {}}}
export function updatesDir(root:string):string{return path.join(root,'.wolf','updates')}
export function updateState(root:string):UpdateState {return readObject(path.join(updatesDir(root),'state.json'))}
export function atomicObject(file:string,value:unknown):void {
fs.mkdirSync(path.dirname(file),{recursive:true});const temp=file+'.'+crypto.randomUUID()+'.tmp';
try{const fd=fs.openSync(temp,'wx',0o600);try{fs.writeFileSync(fd,JSON.stringify(value));fs.fsyncSync(fd)}finally{fs.closeSync(fd)}fs.renameSync(temp,file)}finally{try{fs.unlinkSync(temp)}catch{}}
}
export function updatePolicy(root:string):'compatible'|'all'|'notify'|'off' {
const file=path.join(root,'.wolf','config.json');
let cfg:any;
try {
const parsed=JSON.parse(fs.readFileSync(file,'utf8'));
if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))return 'off';
cfg=parsed.openwolf;
} catch(error) { if((error as NodeJS.ErrnoException).code!=='ENOENT')return 'off'; }
if(cfg?.enabled===false || process.env.OPENWOLF_NO_UPDATE==='1')return 'off';
const mode=cfg?.updates?.mode ?? 'compatible';
return ['compatible','all','notify','off'].includes(mode)?mode:'off';
}
export function installedVersion(root:string):string {
const v=readObject(path.join(root,'.wolf','hooks','runtime.json')).version;
return stableVersion(v)?v:'unknown';
}
export function releasePackage(root:string,version:string):string {
if(!stableVersion(version))throw new Error('Invalid release version');
return path.join(updatesDir(root),'releases',version,'node_modules','openwolf');
}
export function readyRelease(root:string,version:unknown):string|undefined {
if(!stableVersion(version))return;
const pkg=releasePackage(root,version),meta=readObject(path.join(pkg,'package.json'));
const ready=readObject(path.join(updatesDir(root),'releases',version,'ready.json'));
if(meta.name==='openwolf'&&meta.version===version&&meta.openwolfRuntime?.protocol===RUNTIME_PROTOCOL&&ready.version===version)return pkg;
}
// Windows reports uid=0 for ordinary files; it is not POSIX root ownership.
// Protected authority on Windows is unsupported and remains disabled by its verifier.
const protectedRuntime=()=>process.platform!=='win32'&&fs.statSync(fileURLToPath(import.meta.url)).uid===0;
/** Never select a new version for an existing session (including resume). */
export function pinRuntime(root:string,session:string):string|undefined {
if(!session || !stableVersion(installedVersion(root)) || protectedRuntime())return; // protected runtime or no identity => installed
const dir=path.join(updatesDir(root),'pins');
const file=path.join(dir,crypto.createHash('sha256').update(session).digest('hex')+'.json');
let pin=readObject(file);
if(!fs.existsSync(file)) {
const candidate=updateState(root).selected;
const version=updatePolicy(root)!=='off'&&stableVersion(candidate)&&newer(candidate,installedVersion(root))&&readyRelease(root,candidate)?candidate:null;
fs.mkdirSync(dir,{recursive:true});
const tmp=file+'.'+crypto.randomUUID()+'.tmp';
try{fs.writeFileSync(tmp,JSON.stringify({version}),{flag:'wx',mode:0o600});try{fs.linkSync(tmp,file)}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e}}finally{try{fs.unlinkSync(tmp)}catch{}}
pin=readObject(file);
}
return readyRelease(root,pin.version);
}
/** At most one detached, bounded worker per project/check interval. */
export function scheduleUpdate(root:string):void {
try {
if(updatePolicy(root)==='off'||protectedRuntime())return;
const state=updateState(root);
if(typeof state.checkedAt==='number'&&Date.now()-state.checkedAt<CHECK_INTERVAL)return;
const worker=path.join(root,'.wolf','hooks','update-worker.js');
if(!fs.existsSync(worker)||!stableVersion(installedVersion(root)))return;
const dir=updatesDir(root);fs.mkdirSync(dir,{recursive:true});
const request=path.join(dir,'requested');
try {if(Date.now()-fs.statSync(request).mtimeMs<300_000)return;fs.unlinkSync(request)}catch{}
try{fs.writeFileSync(request,'',{flag:'wx',mode:0o600})}catch{return}
const child=spawn(/^bun(?:\.exe)?$/.test(path.basename(process.execPath))?'node':process.execPath,[worker,root],{cwd:dir,detached:true,stdio:'ignore',windowsHide:true});
child.on('error',()=>{try{fs.unlinkSync(request)}catch{}});child.unref();
} catch {} // unavailable npm/network must never interrupt a coding session
}
const bridgeKey=Symbol.for('openwolf.hook.runtime');
export function bridgedInput():string|undefined{return (globalThis as any)[bridgeKey]?.input}
export async function delegateRuntime(root:string,hook:string,input:string):Promise<boolean> {
if((globalThis as any)[bridgeKey])return false;
if(!stableVersion(installedVersion(root)))return false;
// A protected runtime must not execute repository-selected code.
if(protectedRuntime())return false;
let payload:any;try{payload=JSON.parse(input)}catch{return false}
const pkg=pinRuntime(root,payload.session_id ?? '');
if(!pkg || !/^[a-z-]+$/.test(hook))return false;
const entry=path.join(pkg,'dist','hooks',hook+'.js');
if(!fs.existsSync(entry))return false;
(globalThis as any)[bridgeKey]={input};
try{await import(pathToFileURL(entry).href);return true}catch{delete (globalThis as any)[bridgeKey];return false}
}
export function updateNotice(root:string,session:string):string|undefined {
if(!session||updatePolicy(root)==='off'||visibilityMode(root)==='off')return;
const s=updateState(root);if(!stableVersion(s.latest)||!newer(s.latest,installedVersion(root)))return;
if(s.status!=='ready'&&s.status!=='available'&&s.status!=='unsupported')return;
const id=crypto.createHash('sha256').update(s.status+':'+s.latest).digest('hex');
const file=path.join(updatesDir(root),'notices',id);
try{fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,'',{flag:'wx',mode:0o600})}catch{return}
if(s.status==='unsupported')return `OpenWolf ${s.latest} is available; this installation needs a runtime migration.`;
return s.status==='ready'?`OpenWolf ${s.latest} is ready for new sessions; this session keeps its pinned runtime.`:`OpenWolf ${s.latest} is available; automatic upgrade is outside this project's update policy.`;
}
@@ -0,0 +1,20 @@
/** Complete state at every ingress, including a missed SessionStart (#96 @davdittrich). */
export function normalizeSession(value: unknown, id: string, now = new Date().toISOString()): Record<string, any> {
const s: Record<string, any> = value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
s.session_id = id;
if (typeof s.started !== "string" || !Number.isFinite(Date.parse(s.started))) s.started = now;
for (const key of ["files_read", "edit_counts", "reminders_sent", "injected_by_source"]) {
if (!s[key] || typeof s[key] !== "object" || Array.isArray(s[key])) s[key] = {};
}
for (const key of ["files_written", "pending_reminders"]) if (!Array.isArray(s[key])) s[key] = [];
for (const key of ["anatomy_hits", "anatomy_misses", "repeated_reads_warned", "stop_count", "cerebrum_warnings"]) {
if (!Number.isSafeInteger(s[key]) || s[key] < 0) s[key] = 0;
}
for (const [file, r] of Object.entries(s.files_read) as Array<[string, any]>) {
if (!r || typeof r !== "object") { delete s.files_read[file]; continue; }
r.count = Number.isSafeInteger(r.count) && r.count >= 0 ? r.count : 0;
r.tokens = Number.isFinite(r.tokens) && r.tokens >= 0 ? r.tokens : 0;
}
s.files_written = s.files_written.filter((w: any) => w && typeof w.file === "string").map((w: any) => ({ ...w, tokens: Number.isFinite(w.tokens) && w.tokens >= 0 ? w.tokens : 0 }));
return s;
}
+93
View File
@@ -0,0 +1,93 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, timestamp, readMarkdown, sessionFilePath, gcSessionFiles } from "./fs.js"
import type { SessionState } from "./types.js"
const sessions = new Map<string, SessionState>()
export function getSessionState(sessionId: string): SessionState | undefined {
return sessions.get(sessionId)
}
export function setSessionState(sessionId: string, state: SessionState): void {
sessions.set(sessionId, state)
}
export function deleteSession(sessionId: string): void {
sessions.delete(sessionId)
}
export function handleSessionStart(directory: string, sessionId: string): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
fs.mkdirSync(hooksDir, { recursive: true })
gcSessionFiles(hooksDir)
const sessionFile = sessionFilePath(hooksDir, sessionId)
const existing = readJSON<SessionState | null>(sessionFile, null)
if (existing?.session_id === sessionId) { sessions.set(sessionId, existing); return }
const state: SessionState = {
session_id: sessionId,
started: timestamp(),
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
}
sessions.set(sessionId, state)
writeJSON(sessionFile, state)
const memoryPath = path.join(wolfDir, "memory.md")
const now = new Date()
const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`
appendMarkdown(memoryPath, header)
try {
const cerebrumPath = path.join(wolfDir, "cerebrum.md")
const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8")
const stat = fs.statSync(cerebrumPath)
const daysSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24)
const entryLines = cerebrumContent.split("\n").filter(l => {
const t = l.trim()
return t.startsWith("- ") || t.startsWith("* ") || (t.startsWith("[") && t.includes("]"))
})
if (entryLines.length < 3) {
console.warn(`💡 OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session — record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.`)
} else if (daysSinceUpdate > 3) {
console.warn(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`)
}
} catch {}
try {
const buglogPath = path.join(wolfDir, "buglog.json")
const buglog = readJSON<{ bugs: unknown[] }>(buglogPath, { bugs: [] })
if (buglog.bugs.length === 0) {
console.warn(`📋 OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.`)
}
} catch {}
// Count the new session in the lifetime totals. readJSON deep-merges the
// fallback, so a pre-2.0 ledger without `lifetime` still gets the default;
// the guard below covers a ledger where `lifetime` is not an object at all.
const ledgerPath = path.join(wolfDir, "token-ledger.json")
const ledger = readJSON<Record<string, unknown>>(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } }) as {
version: number
lifetime: { total_sessions: number }
[key: string]: unknown
}
if (!ledger.lifetime || typeof ledger.lifetime !== "object") {
ledger.lifetime = { total_sessions: 0 }
}
if (typeof ledger.lifetime.total_sessions !== "number" || !isFinite(ledger.lifetime.total_sessions)) {
ledger.lifetime.total_sessions = 0
}
ledger.lifetime.total_sessions++
writeJSON(ledgerPath, ledger)
}
File diff suppressed because it is too large. Load diff
+250
View File
@@ -0,0 +1,250 @@
import { flushSessionToLedger as flushCanonicalLedger } from "./ledger.js"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, sessionFilePath } from "./fs.js"
import type { SessionState } from "./types.js"
// ─────────────────────────────────────────────────────────────────────────────
// Token-ledger writer (mirrors src/hooks/ledger.ts).
//
// Stop fires at the end of EVERY turn, so the ledger write must be idempotent:
// the session entry is UPSERTED by session id (replaced, never appended), and
// lifetime totals are derived — archived baseline + fold over the retained
// sessions — rather than incremented. The old increment-on-every-Stop scheme
// re-added turns 1..N on turn N, quadratically inflating every lifetime metric
// and duplicating session entries.
// ─────────────────────────────────────────────────────────────────────────────
export const MAX_LEDGER_SESSIONS = 200
interface SessionEntry {
id: string
agent: string
started: string
ended: string
reads: Array<{
file: string
tokens_estimated: number
was_repeated: boolean
anatomy_had_description: boolean
}>
writes: Array<{ file: string; tokens_estimated: number; action: string }>
totals: {
input_tokens_estimated: number
output_tokens_estimated: number
reads_count: number
writes_count: number
repeated_reads_blocked: number
repeated_reads_warned?: number
anatomy_lookups: number
anatomy_misses?: number
savings_estimated?: number
injection_tokens_estimated?: number
}
}
interface LifetimeTotals {
total_tokens_estimated: number
total_reads: number
total_writes: number
total_sessions: number
anatomy_hits: number
anatomy_misses: number
repeated_reads_blocked: number
repeated_reads_warned: number
estimated_savings_vs_bare_cli: number
injection_tokens_estimated: number
[key: string]: number
}
interface LedgerData {
version: number
created_at: string
lifetime: LifetimeTotals
/** Totals folded out of sessions that were rolled off the retained window. */
lifetime_baseline?: Partial<LifetimeTotals>
sessions: SessionEntry[]
[key: string]: unknown
}
function emptyLedger(): LedgerData {
return {
version: 1,
created_at: "",
lifetime: {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
total_sessions: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
},
sessions: [],
daemon_usage: [],
waste_flags: [],
optimization_report: { last_generated: null, patterns: [] },
}
}
/** Build the ledger entry for the current session state (idempotent). */
function buildSessionEntry(session: SessionState): SessionEntry {
const reads = Object.entries(session.files_read).map(([file, data]) => ({
file,
tokens_estimated: data.tokens,
was_repeated: data.count > 1,
anatomy_had_description: data.anatomy_hit === true,
}))
const writes = session.files_written.map((w) => ({
file: w.file,
tokens_estimated: w.tokens,
action: w.action,
}))
return {
id: session.session_id,
agent: "opencode",
started: session.started,
ended: new Date().toISOString(),
reads,
writes,
totals: {
input_tokens_estimated: reads.reduce((sum, r) => sum + r.tokens_estimated, 0),
output_tokens_estimated: writes.reduce((sum, w) => sum + w.tokens_estimated, 0),
reads_count: reads.length,
writes_count: writes.length,
// Honest accounting: only reads the hook actually denied count as
// blocked (warnings do not prevent the read from happening).
repeated_reads_blocked: session.reads_denied ?? 0,
repeated_reads_warned: session.repeated_reads_warned ?? 0,
anatomy_lookups: session.anatomy_hits,
anatomy_misses: session.anatomy_misses,
// Honest savings: tokens of reads that were denied, nothing else.
savings_estimated: session.denied_tokens_saved ?? 0,
injection_tokens_estimated: session.injected_tokens_estimated ?? 0,
},
}
}
function addInto(target: Record<string, number>, key: string, value: number | undefined): void {
if (typeof value !== "number" || !isFinite(value)) return
target[key] = (target[key] ?? 0) + value
}
/** Copy only real numeric fields out of a possibly-partial totals object. */
function numericFields(source: Record<string, number | undefined> | undefined): Record<string, number> {
const out: Record<string, number> = {}
for (const [k, v] of Object.entries(source ?? {})) {
if (typeof v === "number" && isFinite(v)) out[k] = v
}
return out
}
function foldEntry(acc: Record<string, number>, e: SessionEntry): void {
addInto(acc, "total_tokens_estimated", e.totals.input_tokens_estimated + e.totals.output_tokens_estimated)
addInto(acc, "total_reads", e.totals.reads_count)
addInto(acc, "total_writes", e.totals.writes_count)
addInto(acc, "anatomy_hits", e.totals.anatomy_lookups)
addInto(acc, "anatomy_misses", e.totals.anatomy_misses)
addInto(acc, "repeated_reads_blocked", e.totals.repeated_reads_blocked)
addInto(acc, "repeated_reads_warned", e.totals.repeated_reads_warned)
addInto(acc, "estimated_savings_vs_bare_cli", e.totals.savings_estimated)
addInto(acc, "injection_tokens_estimated", e.totals.injection_tokens_estimated)
}
/**
* Derive lifetime = baseline + fold(sessions). total_sessions is intentionally
* NOT derived here — session start counts it once per new session.
*/
function recomputeLifetime(ledger: LedgerData): void {
const acc = numericFields(ledger.lifetime_baseline)
delete acc.total_sessions
for (const e of ledger.sessions) foldEntry(acc, e)
const totalSessions = ledger.lifetime?.total_sessions ?? 0
ledger.lifetime = {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
repeated_reads_warned: 0,
estimated_savings_vs_bare_cli: 0,
injection_tokens_estimated: 0,
...acc,
total_sessions: totalSessions,
} as LifetimeTotals
}
/** Upsert the entry, roll old sessions into the baseline, derive lifetime. */
function flushSessionToLedger(wolfDir: string, entry: SessionEntry): void {
flushCanonicalLedger(wolfDir, entry as any)
}
export function handleStop(directory: string, sessionId: string): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const session = readJSON<SessionState>(sessionFile, {
session_id: "", started: "", files_read: {}, files_written: [],
edit_counts: {}, anatomy_hits: 0, anatomy_misses: 0,
repeated_reads_warned: 0, cerebrum_warnings: 0, stop_count: 0,
})
session.stop_count++
const readCount = Object.keys(session.files_read).length
const writeCount = session.files_written.length
if (readCount === 0 && writeCount === 0) {
writeJSON(sessionFile, session)
return
}
checkForMissingBugLogs(session)
// Idempotent ledger write: the entry for this session id is REPLACED, not
// appended — Stop fires every turn, and appending per turn is what used to
// duplicate sessions and quadratically inflate lifetime totals.
const entry = buildSessionEntry(session)
flushSessionToLedger(wolfDir, entry)
appendSessionSummary(wolfDir, session, readCount, writeCount)
writeJSON(sessionFile, session)
}
function checkForMissingBugLogs(session: SessionState): void {
if (!session.edit_counts) return
const multiEditFiles = Object.entries(session.edit_counts)
.filter(([, count]) => count >= 3)
.map(([file]) => path.basename(file))
if (multiEditFiles.length > 0) {
const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json"))
if (!buglogWritten) {
console.warn(`⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.`)
}
}
}
function appendSessionSummary(wolfDir: string, session: SessionState, readCount: number, writeCount: number): void {
if (writeCount > 0) {
try {
const inputTokens = Object.values(session.files_read).reduce((sum, r) => sum + r.tokens, 0)
const outputTokens = session.files_written.reduce((sum, w) => sum + w.tokens, 0)
const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file)))
const fileList = [...uniqueFiles].slice(0, 5).join(", ")
const memoryPath = path.join(wolfDir, "memory.md")
appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`)
} catch {}
}
}
@@ -0,0 +1,56 @@
import {recordReceipt} from './visibility.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import {fileURLToPath} from 'node:url';
export interface MemoryApproval {
version: 1;
roots: string[];
approved_by: string;
approved_at: string;
deployment: 'managed-harness';
revoked_by?:string;revoked_at?:string;supersedes?:string;
documents: Record<string, {content: string; sha256: string; source: string}>;
}
export interface MemoryTrust {status: 'ready' | 'unavailable' | 'revoked'; reason: string; approval?: MemoryApproval}
const digest = (s: string) => crypto.createHash('sha256').update(s).digest('hex');
/** Owner and effective access both matter (ACLs may grant writes despite mode bits).
* Symlinks are rejected, including writable ancestors. No repository setting can
* grant approval. The authority is provisioned under a separate OS principal.
*/
export function protectedPath(target: string): boolean {
if (process.platform === 'win32' || !process.getuid || process.getuid() === 0) return false;
let p = path.resolve(target);
try {
for (;;) {
const st = fs.lstatSync(p);
if (st.isSymbolicLink() || st.uid !== 0 || (st.mode & 0o022)) return false;
try { fs.accessSync(p, fs.constants.W_OK); return false; } catch {}
const parent = path.dirname(p);
if (parent === p) return true;
p = parent;
}
} catch { return false; }
}
export function memoryTrust(projectRoot: string): MemoryTrust {
const root = fs.realpathSync(projectRoot);
const store = process.env.OPENWOLF_TRUST_STORE ?? (process.platform === 'darwin' ? '/Library/Application Support/OpenWolf/trust' : '/var/lib/openwolf/trust');
// A protected store is insufficient if an agent can replace its verifier.
if (!protectedPath(fileURLToPath(import.meta.url))) return {status:'unavailable',reason:'Protected runtime and managed harness required; durable instruction injection is disabled.'};
const snapshot = path.join(store, digest(root) + '.json');
if (!protectedPath(snapshot)) return {status:'unavailable',reason:'No protected approval snapshot for this project.'};
try {
const a = JSON.parse(fs.readFileSync(snapshot,'utf8')) as MemoryApproval;
if (a.version !== 1 || a.deployment !== 'managed-harness' || !Array.isArray(a.roots) || !a.roots.includes(root) || !a.approved_by || !Number.isFinite(Date.parse(a.approved_at)) || !a.documents) throw new Error('Invalid approval');
if(a.revoked_at){if(!a.revoked_by||!Number.isFinite(Date.parse(a.revoked_at)))throw new Error('Invalid revocation');return {status:'revoked',reason:`Approval revoked by ${a.revoked_by} at ${a.revoked_at}; durable injection is disabled.`};}
for (const [name,d] of Object.entries(a.documents)) {
if (!/^[\w.-]+\.md$/.test(name) || typeof d.content !== 'string' || digest(d.content) !== d.sha256 || !d.source) throw new Error('Invalid approved document');
}
return {status:'ready',reason:'Protected approval snapshot loaded; managed harness is an operator prerequisite.',approval:a};
} catch { return {status:'unavailable',reason:'Approval snapshot failed verification.'}; }
}
export function approvedMemory(wolfDir: string, name: string): string {
try { const document=memoryTrust(path.dirname(wolfDir)).approval?.documents[name];if(document?.content)recordReceipt(path.dirname(wolfDir),{operation:'rule-verified',evidence:document.sha256});return document?.content??''; }
catch { return ''; }
}
+47
View File
@@ -0,0 +1,47 @@
export interface FileRead {
count: number
tokens: number
first_read: string
/** mtime of the file when it was read — repeat warnings only fire while unchanged (issue #41). */
read_mtime?: number
anatomy_hit?: boolean
}
export interface FileWrite {
file: string
action: string
tokens: number
at: string
}
export interface SessionState {
session_id: string
started: string
files_read: Record<string, FileRead>
files_written: FileWrite[]
edit_counts: Record<string, number>
anatomy_hits: number
anatomy_misses: number
repeated_reads_warned: number
reads_denied?: number
denied_tokens_saved?: number
cerebrum_warnings: number
injected_tokens_estimated?: number
stop_count: number
}
export type PartialSessionState = Partial<SessionState>
export interface FixDetection {
category: string
summary: string
rootCause: string
fix: string
context?: string
}
export interface AnatomyEntry {
file: string
description: string
tokens: number
}
+42
View File
@@ -0,0 +1,42 @@
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import * as fs from "node:fs"
import * as path from "node:path"
import * as crypto from "node:crypto"
/** Persist only usage metadata; never transcript text or private reasoning. */
export function recordUsage(directory: string, info: any): void {
if (info?.role !== "assistant" || !info.tokens || !info.id || !info.sessionID) return
const dir = path.join(directory, ".wolf", "usage", "opencode")
fs.mkdirSync(dir, { recursive: true })
const id = crypto.createHash("sha256").update(`${info.sessionID}:${info.id}`).digest("hex")
const file = path.join(dir, id + ".json")
const tmp = file + "." + crypto.randomUUID() + ".tmp"
const message = { id: info.id, sessionID: info.sessionID, role: info.role, providerID: info.providerID, modelID: info.modelID, tokens: info.tokens, time: info.time }
const committed = withFileLock(file + ".lock", HOOK_LOCK_BUDGET_MS, () => {
try {
const previous = JSON.parse(fs.readFileSync(file,"utf8"))?.message
if (previous?.time?.completed && !info.time?.completed) return true
if ((previous?.time?.completed ?? 0) > (info.time?.completed ?? 0)) return true
} catch {}
try {
const fd = fs.openSync(tmp, "wx", 0o600)
try { fs.writeFileSync(fd, JSON.stringify({ version: 1, directory, message })); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
fs.renameSync(tmp, file)
} finally { try { fs.unlinkSync(tmp) } catch {} }
return true
})
if (!committed) throw new Error("OpenCode usage busy; session reconciliation will retry")
}
/** Read-only SDK backfill recovers usage from before plugin installation. */
export async function reconcileOpenCode(directory: string, client: any, sessionId?: string): Promise<void> {
const unwrap = (response: any) => response?.data ?? response;
const sessions = sessionId ? [unwrap(await client.session.get({path:{id:sessionId}}))] : unwrap(await client.session.list({query:{directory}}));
if (!Array.isArray(sessions)) throw new Error("Unsupported OpenCode session list response");
for (const session of sessions) {
if (typeof session?.id !== "string" || typeof session.directory !== "string" || path.resolve(session.directory) !== path.resolve(directory)) continue;
const messages = unwrap(await client.session.messages({path:{id:session.id},query:{directory}}));
if (!Array.isArray(messages)) throw new Error("Unsupported OpenCode messages response");
for (const message of messages) recordUsage(directory,message.info);
}
}
+96
View File
@@ -0,0 +1,96 @@
/** Local activity receipts. Never add model context or change a tool decision. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import {createHash,randomUUID} from 'node:crypto';
import {mutateJSON} from './anatomy-lock.js';
export type VisibilityMode = 'off'|'quiet'|'verbose';
export type Operation = 'checkpoint-saved'|'context-restored'|'memory-archived'|'map-preserved'|'fix-retrieved'|'rule-verified'|'read-denied'|'runtime-ready';
export interface Receipt {schema:1;id:string;session:string;agent:string;operation:Operation;outcome:'completed';at:number;evidence:string;count:number;category:'routine'|'recovery';}
interface State {schema:1;membership?:string;seen:Record<string,number>;watermark:number;history:Receipt[];pending:Receipt[];counts:Partial<Record<Operation,number>>;lastAt:number;turns:Record<string,number>;startup:Record<string,boolean>;last?:{text:string;at:number;session:string;agent:string;transport:string};}
const hash=(s:string)=>createHash('sha256').update(s).digest('hex');
const dir=(root:string)=>path.join(root,'.wolf/activity');
const empty=():State=>({schema:1,seen:{},watermark:0,history:[],pending:[],counts:{},lastAt:0,turns:{},startup:{}});
const operations:Operation[]=['checkpoint-saved','context-restored','memory-archived','map-preserved','fix-retrieved','rule-verified','read-denied','runtime-ready'];
export function visibilityMode(root:string):VisibilityMode {
if(process.env.OPENWOLF_VISIBILITY==='off')return 'off';
try {const file=path.join(root,'.wolf/config.json');if(!fs.existsSync(file))return 'quiet';const mode=JSON.parse(fs.readFileSync(file,'utf8')).openwolf?.visibility?.mode;return mode===undefined?'quiet':mode==='quiet'||mode==='verbose'?mode:'off'}catch{return 'off'}
}
/** Caller supplies completed-operation evidence, never a pending write. Identity is hashed for privacy. */
export function recordReceipt(root:string,input:{operation:Operation;agent?:string;session?:string;evidence:string;count?:number;at?:number}):boolean {
try {
if(visibilityMode(root)==='off'||!fs.existsSync(path.join(root,'.wolf'))||!operations.includes(input.operation)||!input.evidence)return false;
const evidence=hash(input.evidence),id=hash(input.operation+':'+evidence);
const at=input.at??Date.now(),count=input.count??1;
if(!Number.isSafeInteger(count)||count<1||!Number.isFinite(at))return false;
const receipt:Receipt={schema:1,id,session:input.session?hash(input.session):'',agent:['claude','codex','opencode','grok'].includes(input.agent??'')?input.agent!:'project',operation:input.operation,outcome:'completed',at,evidence,count,category:input.operation==='context-restored'?'recovery':'routine'};
const queue=path.join(dir(root),'queue');fs.mkdirSync(queue,{recursive:true});
// Fixed 4096-slot spool: no directory scan, lock, sleep or unbounded backlog on tool paths.
// An occupied slot may drop a receipt, never an operational write. Counters are recorded actions only.
const file=path.join(queue,id.slice(0,3)+'.json'),tmp=file+'.'+randomUUID()+'.tmp';
try{fs.writeFileSync(tmp,JSON.stringify(receipt),{flag:'wx',mode:0o600});try{fs.linkSync(tmp,file);return true}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e;return false}}finally{try{fs.unlinkSync(tmp)}catch{}}
}catch{return false}
}
function readState(root:string):State {try{return JSON.parse(fs.readFileSync(path.join(dir(root),'state.json'),'utf8'))}catch{return empty()}}
function valid(r:Receipt):boolean{return r.schema===1&&/^[a-f0-9]{64}$/.test(r.id)&&/^[a-f0-9]{64}$/.test(r.evidence)&&operations.includes(r.operation)&&r.outcome==='completed'&&Number.isSafeInteger(r.count)&&r.count>0&&Number.isFinite(r.at)&&['claude','codex','opencode','grok','project'].includes(r.agent)&&(r.session===''||/^[a-f0-9]{64}$/.test(r.session))}
export function receiptText(r:Pick<Receipt,'operation'|'count'>):string {
const n=r.count;
const messages:Record<Operation,string>={
'checkpoint-saved':n===1?'Saved context for handover':`Saved ${n} context checkpoints`,
'context-restored':'Restored saved task context',
'memory-archived':`Archived ${n} old session${n===1?'':'s'} · restorable`,
'map-preserved':'Kept existing project map entries after a partial scan',
'fix-retrieved':n===1?'Found a relevant previous fix':`Found ${n} relevant previous fixes`,
'rule-verified':'Loaded reviewed project memory',
'read-denied':n===1?'Declined an unchanged file read':`Declined ${n} unchanged file reads`,
'runtime-ready':'Verified an update for new sessions',
};
return 'OpenWolf · '+messages[r.operation];
}
export type Surface='claude-statusline'|'claude-hook'|'codex-hook'|'opencode-toast'|'dashboard';
/** Boundaries/dashboard only. Zero lock waiting; failed delivery claims remain retryable. */
export function activityState(root:string,delivery?:{agent:string;session:string;turn:string;surface:Surface;startup?:boolean},now=Date.now()):{state:State;message?:string} {
const mode=visibilityMode(root);if(mode==='off'||!fs.existsSync(path.join(root,'.wolf')))return {state:readState(root)};
const queue=path.join(dir(root),'queue'),consumed:Array<{file:string;id:string}>=[];
let message:string|undefined;
try {
const state=mutateJSON<State>(path.join(dir(root),'state.json'),empty(),0,s=>{
const membership=Buffer.from(s.membership??Buffer.alloc(131072).toString('base64'),'base64');
if(membership.length!==131072)throw Error('Invalid receipt membership');
let names:string[]=[];try{names=fs.readdirSync(queue).filter(n=>/^[a-f0-9]{3}\.json$/.test(n)).slice(0,delivery?32:4096)}catch{}
for(const name of names){const file=path.join(queue,name);try {
if(fs.statSync(file).size>2048)continue;const r=JSON.parse(fs.readFileSync(file,'utf8')) as Receipt;if(!valid(r))continue;
const bits=[0,8,16,24].map(i=>parseInt(r.id.slice(i,i+8),16)%(131072*8));
const known=bits.every(bit=>(membership[bit>>3]&(1<<(bit&7)))!==0);
if(!known&&r.at<=now+60_000){for(const bit of bits)membership[bit>>3]|=1<<(bit&7);s.seen[r.id]=r.at;s.history.push(r);s.pending.push(r);s.counts[r.operation]=(s.counts[r.operation]??0)+r.count}
consumed.push({file,id:r.id});
}catch{}}
s.membership=membership.toString('base64');
s.history=s.history.sort((a,b)=>a.at-b.at||a.id.localeCompare(b.id)).slice(-100);
s.pending=s.pending.filter(r=>now-r.at<86400_000).slice(-256);
const seen=Object.entries(s.seen).sort((a,b)=>b[1]-a[1]);
if(seen.length>4096){s.watermark=Math.max(s.watermark,seen[4096][1]);s.seen=Object.fromEntries(seen.filter(([,at])=>at>s.watermark).slice(0,4096))}
if(!delivery||delivery.surface==='dashboard'||!delivery.session)return;
const d=delivery,key=hash(d.agent+':'+d.session),turn=hash(key+':'+d.turn);
if(s.lastAt&&now-s.lastAt<300_000||(s.turns[turn]??0)>=3||d.startup&&s.startup[key])return;
const candidates=s.pending.filter(r=>r.operation!=='read-denied'&&r.operation!=='runtime-ready'&&(!r.session||r.session===hash(d.session)&&r.agent===d.agent)&&(!d.startup||r.category==='recovery')&&
(d.surface!=='claude-hook'&&d.surface!=='codex-hook'||r.category==='recovery'||mode==='verbose'));
const first=candidates.find(r=>r.category==='recovery')??candidates[0];if(!first)return;
const batch=candidates.filter(r=>r.operation===first.operation);message=receiptText({...first,count:batch.reduce((n,r)=>n+r.count,0)});
const ids=new Set(batch.map(r=>r.id));s.pending=s.pending.filter(r=>!ids.has(r.id));s.lastAt=now;s.turns[turn]=(s.turns[turn]??0)+1;
s.turns=Object.fromEntries(Object.entries(s.turns).slice(-256));if(d.startup)s.startup[key]=true;s.startup=Object.fromEntries(Object.entries(s.startup).slice(-256));
s.last={text:message,at:now,session:hash(d.session),agent:d.agent,transport:d.surface};
});
if(!state)return {state:readState(root)};
for(const {file,id} of consumed)try{if(JSON.parse(fs.readFileSync(file,'utf8')).id===id)fs.unlinkSync(file)}catch{}
return {state,message};
}catch{return {state:readState(root)}}
}
export function hookReceipt(root:string,agent:string,input:{session_id?:string;prompt_id?:string},startup=false):string|undefined {
if(agent!=='claude'&&agent!=='codex')return;
return activityState(root,{agent,session:input.session_id??'',turn:input.prompt_id??'session',surface:agent==='claude'?'claude-hook':'codex-hook',startup}).message;
}
/** Missing/headless SDKs are silent; never await rendering or start a model turn. */
export function showActivityToast(client:any,message:string):void {
try{const result=client?.tui?.showToast?.({body:{title:'OpenWolf',message,variant:'info',duration:4000}});void Promise.resolve(result).catch(()=>{})}catch{}
}
+26
View File
@@ -0,0 +1,26 @@
# OpenWolf: machine-local state (committed state: cerebrum.md, STATUS.md,
# memory.md, buglog.json, anatomy.md, anatomy-index.json, config.json, OPENWOLF.md)
hooks/
backups/
cache/
daemon.log
daemon.pid
dashboard-token
token-ledger.json
usage-report.json
updates/
handoff/
activity/
usage/
archive/
session-memory/
ledger-pending/
ledger-sessions/
bug-pending/
*.pending/
suggestions.json
cron-state.json
_scan-state.json
_*.json
*.lock
*.tmp
+44
View File
@@ -0,0 +1,44 @@
# OpenWolf Operating Protocol
You are working in an OpenWolf-managed project. These rules apply every turn.
OpenWolf's hooks handle the bookkeeping: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed (Gemini CLI, Cursor).
## STATUS.md: read first, keep fresh
`.wolf/STATUS.md` is the handoff document. Read it FIRST when resuming a session; it replaces re-reading memory, plans, and code to reconstruct context.
Keep it fresh: when the user signals a quest is done ("done", "ship it", "next phase", "/clear", "wrap up"), move finished items to the done section, write the next quest (objective, files, decisions), and bump the date. Do this before responding "done" on any multi-file task and before suggesting `/clear`. A stale STATUS.md wastes the next session.
## File navigation
1. Before reading an unfamiliar file, grep `.wolf/anatomy.md` for its path to get a one-line description and token estimate. Do NOT read anatomy.md whole; it is an index, not a document.
2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit over whole-file reads.
3. If a file is not in anatomy.md, search with Grep/Glob. Regenerate the index with `openwolf scan`.
## Code generation
1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`.
2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session, a significant decision and its why. The bar is LOW; a redundant entry costs nothing, a missing one repeats the discovery next session.
## Bug logging
Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known.
After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right. The threshold is LOW.
## Token discipline
- Never re-read a file already read this session unless it changed since.
- Prefer anatomy descriptions and targeted Grep over full file reads.
- If appending to a file, do not read the entire file first.
## Session end
Before wrapping up: update `.wolf/STATUS.md`, write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs from the session in cerebrum.md / buglog.json.
## On-demand skills
- `/designqc`: screenshot-based design review of the running app (uses `openwolf designqc`).
- `/reframe`: UI framework selection, migration, and anti-generic design audits.
- `/security-audit`: security review of the project.
+68
View File
@@ -0,0 +1,68 @@
---
description: session handoff, regenerate with /handoff when a quest finishes
budget_tokens: 1000
---
# STATUS — Sabiá
> Single source of truth for resuming work. Read this FIRST when starting a session.
> Update this file at the end of every work phase so the next `/clear` resumes in 1 read.
> Last updated: 2026-10-23
---
## ✅ Done
<!-- Move items here from "🚀 Next phase" when finished. Group by area. -->
- (nothing yet — fill in as work completes)
---
## 🚀 Next phase
**Goal:** _<what we're building next, in 1 sentence>_
### Acceptance criteria
1. _<concrete user-visible outcome>_
2. _<...>_
### Files to create / edit
| Type | File | Content |
|---|---|---|
| new | `path/to/file.ts` | _what it does_ |
### Closed decisions
- _<choice + reasoning>_
### Open decisions
- _<question to ask the user before coding>_
---
## 📁 Active architecture
- **Stack:** _<frameworks, libraries, runtime>_
- **Key tables / modules:** _<list>_
- **Patterns:** _<conventions enforced project-wide>_
---
## ⚠️ External blockers (don't block coding)
- _<env vars, secrets, external accounts, manual steps>_
---
## 🔧 Useful commands
```bash
# add the most-used commands here so the next session has them ready
```
---
## 📚 References (read IF needed)
- `.wolf/cerebrum.md` — User Preferences + Do-Not-Repeat + Decision Log
- `.wolf/anatomy.md` — token-efficient file index
- `.wolf/buglog.json` — known bugs + fixes
+907
View File
@@ -0,0 +1,907 @@
{
"version": 1,
"meta": {
"lastScanned": "2026-10-23T11:21:49.966Z",
"fileCount": 22,
"hits": 0,
"misses": 0,
"renderedHash": "2030278ebfc7a2591c4824586f15125940d579ecad61b4e8cb5a9a9bf6b3a399",
"storeUpdatedAt": "2026-10-23T11:21:49.967Z",
"rootHash": "77f386ab99dd2782"
},
"files": {
".gitignore": {
"description": "Git ignore rules",
"tokens": 24,
"hash": "465569a17f6c1fcd",
"size": 89,
"mtimeMs": 1792754328319.2644,
"updatedAt": "2026-10-23T11:21:49.944Z",
"source": "scan",
"importance": 0.1527
},
"AGENTS.md": {
"description": "OpenWolf",
"tokens": 75,
"hash": "e401f79554a8d796",
"size": 297,
"mtimeMs": 1792754075050.7603,
"updatedAt": "2026-10-23T11:21:49.944Z",
"source": "scan",
"importance": 0.1527
},
"Cargo.toml": {
"description": "Rust package manifest",
"tokens": 100,
"hash": "c445aa952b469eef",
"size": 351,
"mtimeMs": 1792754084001.563,
"updatedAt": "2026-10-23T11:21:49.945Z",
"source": "scan",
"importance": 0.1527
},
"CLAUDE.md": {
"description": "OpenWolf",
"tokens": 99,
"hash": "074d6b42b583729a",
"size": 395,
"mtimeMs": 1792754068779.6982,
"updatedAt": "2026-10-23T11:21:49.945Z",
"source": "scan",
"importance": 0.1527
},
"crates/sabia-cli/Cargo.toml": {
"description": "Rust package manifest",
"tokens": 125,
"hash": "dc1447c043cb895b",
"size": 436,
"mtimeMs": 1792754292996.8179,
"updatedAt": "2026-10-23T11:21:49.945Z",
"source": "scan",
"importance": 0.1527
},
"crates/sabia-cli/src/main.rs": {
"description": "Lista interfaces de rede e mostra quais são Wi-Fi.",
"tokens": 2193,
"hash": "2f37e8d34c3f1e7d",
"size": 7696,
"mtimeMs": 1792754462287.5278,
"updatedAt": "2026-10-23T11:21:49.945Z",
"source": "scan",
"symbols": [
{
"name": "web",
"kind": "section",
"startLine": 1,
"endLine": 1,
"tokens": 3
},
{
"name": "Cli",
"kind": "class",
"startLine": 12,
"endLine": 15,
"tokens": 16
},
{
"name": "Cmd",
"kind": "class",
"startLine": 18,
"endLine": 49,
"tokens": 285
},
{
"name": "IfaceAction",
"kind": "class",
"startLine": 52,
"endLine": 56,
"tokens": 43
},
{
"name": "OnOff",
"kind": "class",
"startLine": 59,
"endLine": 62,
"tokens": 9
},
{
"name": "audit_path",
"kind": "fn",
"startLine": 64,
"endLine": 66,
"tokens": 20
},
{
"name": "main",
"kind": "fn",
"startLine": 68,
"endLine": 74,
"tokens": 41
},
{
"name": "run",
"kind": "fn",
"startLine": 76,
"endLine": 216,
"tokens": 1643
}
],
"symbolSource": "ts",
"skeleton": "mod web;\nstruct Cli {\nenum Cmd {\nenum IfaceAction {\nenum OnOff {\nfn audit_path() -> PathBuf {\nfn main() {\nfn run(cli: Cli) -> sabia_core::Result<()> {",
"imports": [
"crates/sabia-cli/src/web/mod.rs"
],
"importance": 0.1527
},
"crates/sabia-cli/src/web/index.html": {
"description": "Sabiá",
"tokens": 1684,
"hash": "4344229aaa5e2db1",
"size": 6334,
"mtimeMs": 1792754445269.6348,
"updatedAt": "2026-10-23T11:21:49.946Z",
"source": "scan",
"importance": 0.1527
},
"crates/sabia-cli/src/web/mod.rs": {
"description": "Servidor local da interface: a mesma filosofia do Guará (`guara ui`) —",
"tokens": 2254,
"hash": "a6aa02205c19274e",
"size": 7896,
"mtimeMs": 1792754470284.2668,
"updatedAt": "2026-10-23T11:21:49.946Z",
"source": "scan",
"symbols": [
{
"name": "Session",
"kind": "class",
"startLine": 17,
"endLine": 20,
"tokens": 24
},
{
"name": "AppState",
"kind": "class",
"startLine": 23,
"endLine": 25,
"tokens": 19
},
{
"name": "serve",
"kind": "fn",
"startLine": 27,
"endLine": 47,
"tokens": 190
},
{
"name": "gen_token",
"kind": "fn",
"startLine": 49,
"endLine": 53,
"tokens": 52
},
{
"name": "open_browser",
"kind": "fn",
"startLine": 55,
"endLine": 57,
"tokens": 36
},
{
"name": "Request",
"kind": "class",
"startLine": 59,
"endLine": 64,
"tokens": 34
},
{
"name": "parse_request",
"kind": "fn",
"startLine": 66,
"endLine": 94,
"tokens": 286
},
{
"name": "urldecode",
"kind": "fn",
"startLine": 96,
"endLine": 98,
"tokens": 34
},
{
"name": "handle",
"kind": "fn",
"startLine": 100,
"endLine": 119,
"tokens": 227
},
{
"name": "respond",
"kind": "fn",
"startLine": 121,
"endLine": 131,
"tokens": 119
},
{
"name": "api",
"kind": "fn",
"startLine": 133,
"endLine": 174,
"tokens": 531
},
{
"name": "start_scan",
"kind": "fn",
"startLine": 176,
"endLine": 215,
"tokens": 439
},
{
"name": "stop_scan",
"kind": "fn",
"startLine": 217,
"endLine": 222,
"tokens": 60
}
],
"symbolSource": "ts",
"skeleton": "struct Session {\nstruct AppState {\npub fn serve(port: u16) -> sabia_core::Result<()> {\nfn gen_token() -> String {\nfn open_browser(url: &str) -> std::io::Result<()> {\nstruct Request {\nfn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {\nfn urldecode(s: &str) -> String {\nfn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) -> std::io::Result<()> {\nfn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::io::Result<()> {\nfn api(state: &Arc<AppState>, req: &Request) -> String {\nfn start_scan(state: &Arc<AppState>, name: String) -> String {\nfn stop_scan(state: &Arc<AppState>, name: &str) {",
"importance": 0.2825
},
"crates/sabia-core/Cargo.toml": {
"description": "Rust package manifest",
"tokens": 190,
"hash": "09752951731f7901",
"size": 670,
"mtimeMs": 1792754101085.2576,
"updatedAt": "2026-10-23T11:21:49.946Z",
"source": "scan",
"importance": 0.1527
},
"crates/sabia-core/src/audit.rs": {
"description": "Trilha de auditoria encadeada por hash de toda ação sensível (ligar",
"tokens": 1151,
"hash": "5b60f2b256cb178b",
"size": 4052,
"mtimeMs": 1792754254667.1384,
"updatedAt": "2026-10-23T11:21:49.946Z",
"source": "scan",
"symbols": [
{
"name": "AuditEntry",
"kind": "class",
"startLine": 20,
"endLine": 27,
"tokens": 50
},
{
"name": "AuditLog",
"kind": "class",
"startLine": 29,
"endLine": 32,
"tokens": 22
},
{
"name": "AuditLog",
"kind": "section",
"startLine": 36,
"endLine": 114,
"tokens": 828
},
{
"name": "AuditLog.open",
"kind": "method",
"startLine": 37,
"endLine": 57,
"tokens": 214
},
{
"name": "AuditLog.record",
"kind": "method",
"startLine": 59,
"endLine": 83,
"tokens": 271
},
{
"name": "AuditLog.verify",
"kind": "method",
"startLine": 87,
"endLine": 113,
"tokens": 305
}
],
"symbolSource": "ts",
"imports": [
"crates/sabia-core/src/error.rs"
],
"importance": 0.1657
},
"crates/sabia-core/src/capture.rs": {
"description": "Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.",
"tokens": 1114,
"hash": "b2f202d231708f43",
"size": 3915,
"mtimeMs": 1792754351620.4128,
"updatedAt": "2026-10-23T11:21:49.946Z",
"source": "scan",
"symbols": [
{
"name": "RawCapture",
"kind": "class",
"startLine": 19,
"endLine": 21,
"tokens": 12
},
{
"name": "RawCapture",
"kind": "section",
"startLine": 23,
"endLine": 94,
"tokens": 726
},
{
"name": "RawCapture.open",
"kind": "method",
"startLine": 26,
"endLine": 58,
"tokens": 310
},
{
"name": "RawCapture.set_read_timeout",
"kind": "method",
"startLine": 62,
"endLine": 80,
"tokens": 184
},
{
"name": "RawCapture.recv",
"kind": "method",
"startLine": 83,
"endLine": 93,
"tokens": 126
},
{
"name": "AsRawFd",
"kind": "section",
"startLine": 96,
"endLine": 100,
"tokens": 26
},
{
"name": "AsRawFd.as_raw_fd",
"kind": "method",
"startLine": 97,
"endLine": 99,
"tokens": 16
},
{
"name": "Drop",
"kind": "section",
"startLine": 102,
"endLine": 106,
"tokens": 29
},
{
"name": "Drop.drop",
"kind": "method",
"startLine": 103,
"endLine": 105,
"tokens": 21
},
{
"name": "if_index",
"kind": "fn",
"startLine": 108,
"endLine": 115,
"tokens": 90
},
{
"name": "check_capabilities",
"kind": "fn",
"startLine": 119,
"endLine": 122,
"tokens": 28
}
],
"symbolSource": "ts",
"imports": [
"crates/sabia-core/src/error.rs"
],
"importance": 0.1657
},
"crates/sabia-core/src/dot11.rs": {
"description": "Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe",
"tokens": 1655,
"hash": "f6ce71057b70544a",
"size": 5804,
"mtimeMs": 1792754184829.32,
"updatedAt": "2026-10-23T11:21:49.946Z",
"source": "scan",
"symbols": [
{
"name": "FrameKind",
"kind": "class",
"startLine": 5,
"endLine": 14,
"tokens": 41
},
{
"name": "Security",
"kind": "class",
"startLine": 17,
"endLine": 23,
"tokens": 20
},
{
"name": "ManagementInfo",
"kind": "class",
"startLine": 26,
"endLine": 30,
"tokens": 37
},
{
"name": "mac_to_string",
"kind": "fn",
"startLine": 32,
"endLine": 34,
"tokens": 44
},
{
"name": "Frame",
"kind": "class",
"startLine": 37,
"endLine": 46,
"tokens": 96
},
{
"name": "parse",
"kind": "fn",
"startLine": 48,
"endLine": 83,
"tokens": 389
},
{
"name": "bssid_str",
"kind": "method",
"startLine": 86,
"endLine": 88,
"tokens": 29
},
{
"name": "src_str",
"kind": "method",
"startLine": 90,
"endLine": 92,
"tokens": 28
},
{
"name": "management_info",
"kind": "method",
"startLine": 96,
"endLine": 162,
"tokens": 635
},
{
"name": "is_eapol",
"kind": "fn",
"startLine": 167,
"endLine": 183,
"tokens": 160
}
],
"symbolSource": "ts",
"importance": 0.2361
},
"crates/sabia-core/src/error.rs": {
"description": "[derive(Debug, thiserror::Error)]",
"tokens": 225,
"hash": "02139521fcac88db",
"size": 797,
"mtimeMs": 1792754108274.089,
"updatedAt": "2026-10-23T11:21:49.947Z",
"source": "scan",
"importance": 1
},
"crates/sabia-core/src/iface.rs": {
"description": "Descoberta de interfaces de rede e identificação das que são Wi-Fi.",
"tokens": 973,
"hash": "bc45dfdc47a527fc",
"size": 3427,
"mtimeMs": 1792754124447.7615,
"updatedAt": "2026-10-23T11:21:49.947Z",
"source": "scan",
"symbols": [
{
"name": "Interface",
"kind": "class",
"startLine": 11,
"endLine": 18,
"tokens": 59
},
{
"name": "Mode",
"kind": "class",
"startLine": 21,
"endLine": 25,
"tokens": 16
},
{
"name": "net_root",
"kind": "fn",
"startLine": 27,
"endLine": 29,
"tokens": 19
},
{
"name": "list_all",
"kind": "fn",
"startLine": 32,
"endLine": 44,
"tokens": 110
},
{
"name": "list_wireless",
"kind": "fn",
"startLine": 47,
"endLine": 49,
"tokens": 35
},
{
"name": "get",
"kind": "fn",
"startLine": 51,
"endLine": 57,
"tokens": 57
},
{
"name": "read_interface",
"kind": "fn",
"startLine": 59,
"endLine": 98,
"tokens": 372
},
{
"name": "read_wireless_mode",
"kind": "fn",
"startLine": 104,
"endLine": 110,
"tokens": 62
}
],
"symbolSource": "ts",
"imports": [
"crates/sabia-core/src/error.rs"
],
"importance": 0.2361
},
"crates/sabia-core/src/lib.rs": {
"description": "`sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11",
"tokens": 140,
"hash": "e65164872526b1e7",
"size": 495,
"mtimeMs": 1792754288188.87,
"updatedAt": "2026-10-23T11:21:49.947Z",
"source": "scan",
"imports": [
"crates/sabia-core/src/audit.rs",
"crates/sabia-core/src/dot11.rs",
"crates/sabia-core/src/error.rs",
"crates/sabia-core/src/iface.rs",
"crates/sabia-core/src/netlink.rs",
"crates/sabia-core/src/pcapwriter.rs",
"crates/sabia-core/src/radiotap.rs",
"crates/sabia-core/src/scan.rs",
"crates/sabia-core/src/update.rs",
"crates/sabia-core/src/capture.rs"
],
"importance": 0.1527
},
"crates/sabia-core/src/netlink.rs": {
"description": "Controle de modo (managed/monitor) e canal via `iw`.",
"tokens": 996,
"hash": "6a27b03cef2c283e",
"size": 3500,
"mtimeMs": 1792754142459.2986,
"updatedAt": "2026-10-23T11:21:49.947Z",
"source": "scan",
"symbols": [
{
"name": "run",
"kind": "fn",
"startLine": 13,
"endLine": 34,
"tokens": 246
},
{
"name": "iw_dev_type",
"kind": "fn",
"startLine": 36,
"endLine": 49,
"tokens": 123
},
{
"name": "supported_channels",
"kind": "fn",
"startLine": 52,
"endLine": 80,
"tokens": 294
},
{
"name": "set_monitor_mode",
"kind": "fn",
"startLine": 83,
"endLine": 89,
"tokens": 84
},
{
"name": "set_channel",
"kind": "fn",
"startLine": 92,
"endLine": 95,
"tokens": 42
}
],
"symbolSource": "ts",
"imports": [
"crates/sabia-core/src/error.rs",
"crates/sabia-core/src/iface.rs"
],
"importance": 0.1657
},
"crates/sabia-core/src/pcapwriter.rs": {
"description": "Escritor de arquivos `.pcap` (formato clássico, link-type 127 =",
"tokens": 507,
"hash": "2d6921c66f9e7506",
"size": 1781,
"mtimeMs": 1792754240112.29,
"updatedAt": "2026-10-23T11:21:49.947Z",
"source": "scan",
"symbols": [
{
"name": "PcapWriter",
"kind": "class",
"startLine": 13,
"endLine": 15,
"tokens": 15
},
{
"name": "PcapWriter",
"kind": "section",
"startLine": 17,
"endLine": 47,
"tokens": 379
},
{
"name": "PcapWriter.create",
"kind": "method",
"startLine": 18,
"endLine": 30,
"tokens": 186
},
{
"name": "PcapWriter.write_frame",
"kind": "method",
"startLine": 33,
"endLine": 41,
"tokens": 140
},
{
"name": "PcapWriter.flush",
"kind": "method",
"startLine": 43,
"endLine": 46,
"tokens": 26
}
],
"symbolSource": "ts",
"imports": [
"crates/sabia-core/src/error.rs"
],
"importance": 0.1657
},
"crates/sabia-core/src/radiotap.rs": {
"description": "Parser mínimo de cabeçalho Radiotap — só os campos que usamos",
"tokens": 881,
"hash": "3dc5e23c73e7e66a",
"size": 3104,
"mtimeMs": 1792754360436.5854,
"updatedAt": "2026-10-23T11:21:49.948Z",
"source": "scan",
"symbols": [
{
"name": "RadiotapInfo",
"kind": "class",
"startLine": 6,
"endLine": 10,
"tokens": 34
},
{
"name": "parse",
"kind": "fn",
"startLine": 20,
"endLine": 94,
"tokens": 626
},
{
"name": "freq_to_channel",
"kind": "fn",
"startLine": 96,
"endLine": 103,
"tokens": 73
}
],
"symbolSource": "ts",
"importance": 0.2361
},
"crates/sabia-core/src/scan.rs": {
"description": "Agregação de quadros capturados em uma tabela de APs e clientes vistos,",
"tokens": 1128,
"hash": "bcd7f984398bfdc4",
"size": 3963,
"mtimeMs": 1792754229534.2896,
"updatedAt": "2026-10-23T11:21:49.948Z",
"source": "scan",
"symbols": [
{
"name": "AccessPoint",
"kind": "class",
"startLine": 11,
"endLine": 20,
"tokens": 76
},
{
"name": "ScanTable",
"kind": "class",
"startLine": 23,
"endLine": 25,
"tokens": 18
},
{
"name": "ScanTable",
"kind": "section",
"startLine": 27,
"endLine": 102,
"tokens": 842
},
{
"name": "ScanTable.new",
"kind": "method",
"startLine": 28,
"endLine": 30,
"tokens": 16
},
{
"name": "ScanTable.ingest",
"kind": "method",
"startLine": 36,
"endLine": 95,
"tokens": 696
},
{
"name": "ScanTable.access_points",
"kind": "method",
"startLine": 97,
"endLine": 101,
"tokens": 57
},
{
"name": "security_label",
"kind": "fn",
"startLine": 104,
"endLine": 112,
"tokens": 77
}
],
"symbolSource": "ts",
"imports": [
"crates/sabia-core/src/dot11.rs",
"crates/sabia-core/src/radiotap.rs"
],
"importance": 0.1657
},
"crates/sabia-core/src/update.rs": {
"description": "Auto-atualização simples: verifica a última release no GitHub, baixa o",
"tokens": 1772,
"hash": "03ad228ca62475e3",
"size": 6236,
"mtimeMs": 1792754282046.0845,
"updatedAt": "2026-10-23T11:21:49.948Z",
"source": "scan",
"symbols": [
{
"name": "Release",
"kind": "class",
"startLine": 18,
"endLine": 21,
"tokens": 19
},
{
"name": "Asset",
"kind": "class",
"startLine": 24,
"endLine": 27,
"tokens": 20
},
{
"name": "UpdateStatus",
"kind": "class",
"startLine": 30,
"endLine": 34,
"tokens": 38
},
{
"name": "curl_text",
"kind": "fn",
"startLine": 36,
"endLine": 48,
"tokens": 152
},
{
"name": "curl_download",
"kind": "fn",
"startLine": 50,
"endLine": 61,
"tokens": 138
},
{
"name": "asset_name_for_platform",
"kind": "fn",
"startLine": 63,
"endLine": 73,
"tokens": 91
},
{
"name": "check",
"kind": "fn",
"startLine": 77,
"endLine": 99,
"tokens": 260
},
{
"name": "apply",
"kind": "fn",
"startLine": 103,
"endLine": 162,
"tokens": 693
},
{
"name": "find_binary",
"kind": "fn",
"startLine": 164,
"endLine": 173,
"tokens": 109
}
],
"symbolSource": "ts",
"imports": [
"crates/sabia-core/src/error.rs"
],
"importance": 0.1657
},
"GEMINI.md": {
"description": "OpenWolf",
"tokens": 75,
"hash": "e401f79554a8d796",
"size": 297,
"mtimeMs": 1792754075057.7603,
"updatedAt": "2026-10-23T11:21:49.948Z",
"source": "scan",
"importance": 0.1527
},
"README.md": {
"description": "Project documentation",
"tokens": 462,
"hash": "04a8387e4a13cf9f",
"size": 1887,
"mtimeMs": 1792754508878.1196,
"updatedAt": "2026-10-23T11:21:49.949Z",
"source": "scan",
"importance": 0.1527
}
},
"preamble": [
"> Project structure index. Auto-maintained by OpenWolf hooks and daemon.",
"> Run `openwolf scan` to generate, or wait for the first Claude Code session.",
"> Status: Pending initial scan"
]
}
+48
View File
@@ -0,0 +1,48 @@
# anatomy.md
> Auto-maintained by OpenWolf. Last scanned: 2026-10-23T11:21:49.966Z
> Files: 22 tracked | Anatomy hits: 0 | Misses: 0
> Project structure index. Auto-maintained by OpenWolf hooks and daemon.
> Run `openwolf scan` to generate, or wait for the first Claude Code session.
> Status: Pending initial scan
## ./
- `.gitignore` — Git ignore rules (~24 tok)
- `AGENTS.md` — OpenWolf (~75 tok)
- `Cargo.toml` — Rust package manifest (~100 tok)
- `CLAUDE.md` — OpenWolf (~99 tok)
- `GEMINI.md` — OpenWolf (~75 tok)
- `README.md` — Project documentation (~462 tok)
## crates/sabia-cli/
- `Cargo.toml` — Rust package manifest (~125 tok)
## crates/sabia-cli/src/
- `main.rs` — Lista interfaces de rede e mostra quais são Wi-Fi. (~2193 tok)
## crates/sabia-cli/src/web/
- `index.html` — Sabiá (~1684 tok)
- `mod.rs` — Servidor local da interface: a mesma filosofia do Guará (`guara ui`) — (~2254 tok)
## crates/sabia-core/
- `Cargo.toml` — Rust package manifest (~190 tok)
## crates/sabia-core/src/
- `audit.rs` — Trilha de auditoria encadeada por hash de toda ação sensível (ligar (~1151 tok)
- `capture.rs` — Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap. (~1114 tok)
- `dot11.rs` — Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe (~1655 tok)
- `error.rs` — [derive(Debug, thiserror::Error)] (~225 tok)
- `iface.rs` — Descoberta de interfaces de rede e identificação das que são Wi-Fi. (~973 tok)
- `lib.rs` — `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11 (~140 tok)
- `netlink.rs` — Controle de modo (managed/monitor) e canal via `iw`. (~996 tok)
- `pcapwriter.rs` — Escritor de arquivos `.pcap` (formato clássico, link-type 127 = (~507 tok)
- `radiotap.rs` — Parser mínimo de cabeçalho Radiotap — só os campos que usamos (~881 tok)
- `scan.rs` — Agregação de quadros capturados em uma tabela de APs e clientes vistos, (~1128 tok)
- `update.rs` — Auto-atualização simples: verifica a última release no GitHub, baixa o (~1772 tok)
+27
View File
@@ -0,0 +1,27 @@
{
"version": 1,
"bugs": [
{
"error_message": "Wrong reference: Read should be Write",
"file": "crates/sabia-cli/src/web/mod.rs",
"root_cause": "Used \"Read\" instead of \"Write\"",
"fix": "Changed Read → Write",
"tags": [
"auto-detected",
"wrong-reference",
"rs"
],
"status": "candidate",
"observed_worktree": "/home/lucas/Documents/Pessoal/dev/Sabiá",
"id": "bug-001",
"timestamp": "2026-10-23T11:21:10.367Z",
"last_seen": "2026-10-23T11:21:10.367Z",
"occurrences": 1,
"related_bugs": []
}
],
"applied_observations": {
"0c11d054-2b71-4e02-a624-0e73a302bc1e": true
},
"next_id": 2
}
+26
View File
@@ -0,0 +1,26 @@
---
description: learned preferences, project conventions, and Do-Not-Repeat rules
budget_tokens: 2000
---
# Cerebrum
> OpenWolf's learning memory. Updated automatically as the AI learns from interactions.
> Do not edit manually unless correcting an error.
> Last updated: 2026-10-23
## User Preferences
<!-- How the user likes things done. Code style, tools, patterns, communication. -->
## Key Learnings
- **Project:** Sabiá
## Do-Not-Repeat
<!-- Mistakes made and corrected. Each entry prevents the same mistake recurring. -->
<!-- Format: [YYYY-MM-DD] Description of what went wrong and what to do instead. -->
## Decision Log
<!-- Significant technical decisions with rationale. Why X was chosen over Y. -->
+126
View File
@@ -0,0 +1,126 @@
{
"version": 1,
"openwolf": {
"enabled": true,
"reads": {
"duplicate_mode": "warn",
"skeleton_hints": true
},
"bash": {
"filter_mode": "suggest",
"governor": {
"mode": "replace",
"threshold_tokens": 2000,
"families": {
"grep_flood": "replace",
"file_print": "replace",
"git_show": "replace",
"test": "suggest",
"build": "suggest",
"unknown": "suggest"
}
}
},
"anatomy": {
"auto_scan_on_init": true,
"rescan_interval_hours": 6,
"max_description_length": 100,
"max_files": 500,
"exclude_patterns": [
"node_modules",
"dist",
"build",
".next",
".nuxt",
".turbo",
".vercel",
".netlify",
".output",
"coverage",
"__pycache__",
".venv",
"venv",
"site-packages",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".tox",
"*.pyc",
".gradle",
"target",
".m2",
".cargo",
".git",
".wolf",
".cache",
".vscode",
".idea",
".DS_Store",
"Thumbs.db",
"*.min.js",
"*.min.css",
"*.map"
],
"respect_gitignore": true
},
"token_audit": {
"enabled": true,
"report_frequency": "weekly",
"waste_threshold_percent": 15,
"chars_per_token_code": 3.5,
"chars_per_token_prose": 4
},
"cron": {
"enabled": true,
"max_retry_attempts": 3,
"dead_letter_enabled": true,
"heartbeat_interval_minutes": 30
},
"memory": {
"consolidation_after_days": 7,
"max_entries_before_consolidation": 200
},
"cerebrum": {
"max_tokens": 2000,
"reflection_frequency": "weekly"
},
"daemon": {
"port": 18830,
"log_level": "info"
},
"dashboard": {
"enabled": true,
"port": 18831,
"host": "127.0.0.1"
},
"context": {
"session_digest_budget_tokens": 1500,
"reinjection_interval": 25,
"state_budgets": {
".wolf/cerebrum.md": 2000,
".wolf/STATUS.md": 1000
},
"budgets": {
"claude": 1500,
"codex": 1200,
"gemini": 1200,
"opencode": 1200,
"cursor": 800
}
},
"visibility": {
"mode": "quiet"
},
"updates": {
"mode": "compatible"
},
"agents": [
"claude",
"codex",
"opencode",
"gemini",
"cursor",
"antigravity"
]
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"version": 1,
"tasks": [
{
"id": "anatomy-rescan",
"name": "Full anatomy rescan",
"schedule": "0 */6 * * *",
"description": "Re-scans project filesystem and reconciles anatomy.md",
"action": {
"type": "scan_project"
},
"retry": {
"max_attempts": 3,
"backoff": "exponential",
"base_delay_seconds": 30
},
"failsafe": {
"on_failure": "log_and_continue",
"alert_after_consecutive_failures": 2,
"dead_letter": true
},
"enabled": true
},
{
"id": "memory-consolidation",
"name": "Consolidate old memory",
"schedule": "0 2 * * *",
"description": "Compress memory.md entries older than 7 days",
"action": {
"type": "consolidate_memory",
"params": {
"older_than_days": 7
}
},
"retry": {
"max_attempts": 2,
"backoff": "exponential",
"base_delay_seconds": 60
},
"failsafe": {
"on_failure": "skip_and_retry_next_cycle",
"dead_letter": false
},
"enabled": true
},
{
"id": "token-audit",
"name": "Token audit report",
"schedule": "0 0 * * 1",
"description": "Weekly waste pattern detection",
"action": {
"type": "generate_token_report"
},
"retry": {
"max_attempts": 2,
"backoff": "linear",
"base_delay_seconds": 60
},
"failsafe": {
"on_failure": "log_and_continue",
"dead_letter": true
},
"enabled": true
}
]
}
+34
View File
@@ -0,0 +1,34 @@
---
description: chronological action log per session, consolidated weekly
---
# Memory
> Chronological action log. Hooks and AI append to this file automatically.
> Old sessions are consolidated by the daemon weekly.
| 08:14 | Created Cargo.toml | — | ~94 |
| 08:15 | Created crates/sabia-core/Cargo.toml | — | ~177 |
| 08:15 | Created crates/sabia-core/src/error.rs | — | ~210 |
| 08:15 | Created crates/sabia-core/src/iface.rs | — | ~908 |
| 08:15 | Created crates/sabia-core/src/netlink.rs | — | ~930 |
| 08:16 | Created crates/sabia-core/src/radiotap.rs | — | ~828 |
| 08:16 | Created crates/sabia-core/src/dot11.rs | — | ~1544 |
| 08:16 | Created crates/sabia-core/src/capture.rs | — | ~1069 |
| 08:16 | Edited crates/sabia-core/src/capture.rs | 3→3 lines | ~17 |
| 08:16 | Edited crates/sabia-core/src/capture.rs | — | ~0 |
| 08:17 | Created crates/sabia-core/src/scan.rs | — | ~1053 |
| 08:17 | Created crates/sabia-core/src/pcapwriter.rs | — | ~474 |
| 08:17 | Created crates/sabia-core/src/audit.rs | — | ~1074 |
| 08:18 | Created crates/sabia-core/src/update.rs | — | ~1654 |
| 08:18 | Created crates/sabia-core/src/lib.rs | — | ~131 |
| 08:18 | Created crates/sabia-cli/Cargo.toml | — | ~117 |
| 08:18 | Created crates/sabia-cli/src/main.rs | — | ~1987 |
| 08:18 | Created .gitignore | — | ~24 |
| 08:19 | Edited crates/sabia-core/src/capture.rs | inline fix | ~18 |
| 08:19 | Edited crates/sabia-core/src/radiotap.rs | modified len() | ~47 |
| 08:20 | Created crates/sabia-cli/src/web/mod.rs | — | ~2105 |
| 08:20 | Created crates/sabia-cli/src/web/index.html | — | ~1684 |
| 08:20 | Edited crates/sabia-cli/src/main.rs | 6→8 lines | ~72 |
| 08:20 | Edited crates/sabia-cli/src/main.rs | 3→8 lines | ~63 |
| 08:21 | Edited crates/sabia-cli/src/main.rs | 5→9 lines | ~83 |
| 08:21 | Edited crates/sabia-cli/src/web/mod.rs | inline fix | ~11 |
| 08:21 | Created README.md | — | ~493 |
+5
View File
@@ -0,0 +1,5 @@
<!-- openwolf:begin -->
# OpenWolf
This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index).
<!-- openwolf:end -->
+5
View File
@@ -0,0 +1,5 @@
# OpenWolf
This project uses OpenWolf for context management. The always-on rules live in `.claude/rules/openwolf.md`; the hooks handle bookkeeping (anatomy index, memory log, read tracking) automatically.
For the full operating protocol (session handoff, memory discipline, bug logging), load the `openwolf` skill, or read `.wolf/OPENWOLF.md`. Regenerate the session handoff with `/handoff`.
Generated
+495
View File
@@ -0,0 +1,495 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys",
]
[[package]]
name = "deranged"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "sabia-cli"
version = "0.1.0"
dependencies = [
"clap",
"ctrlc",
"sabia-core",
"serde_json",
]
[[package]]
name = "sabia-core"
version = "0.1.0"
dependencies = [
"libc",
"serde",
"serde_json",
"sha2",
"thiserror",
"time",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+17
View File
@@ -0,0 +1,17 @@
[workspace]
resolver = "2"
members = ["crates/sabia-core", "crates/sabia-cli"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/lucasrguerra/sabia"
authors = ["Sabiá contributors"]
rust-version = "1.80"
[profile.release]
lto = "thin"
codegen-units = 1
opt-level = 3
strip = true
+5
View File
@@ -0,0 +1,5 @@
<!-- openwolf:begin -->
# OpenWolf
This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index).
<!-- openwolf:end -->
+47
View File
@@ -0,0 +1,47 @@
# Sabiá
Auditoria, monitoramento e pentest de redes Wi-Fi — terminal e interface
gráfica sobre o mesmo núcleo, nos moldes do [Guará](../Guará): a parte
complexa (netlink, radiotap, parsing de quadro 802.11) fica escondida em
`sabia-core`; CLI e GUI só consomem.
> Use apenas em redes próprias ou com autorização explícita do responsável
> pela rede (engajamento de pentest formal). Toda ativação de modo monitor
> e sessão de captura é registrada numa trilha de auditoria local
> encadeada por hash (`.sabia-audit.log`).
## Estrutura
```
crates/sabia-core biblioteca: netlink (iw), radiotap, parsing 802.11,
captura crua (AF_PACKET), auditoria, auto-atualização
crates/sabia-cli binário `sabia`: comandos de terminal + servidor
local da interface (`sabia ui`)
apps/gui/ui mesma página HTML/JS servida pelo `sabia ui`
apps/gui/src-tauri shell desktop (Tauri) — esqueleto, igual ao Guará
```
## Uso
```
sabia iface list # lista interfaces, marca as Wi-Fi
sabia iface monitor <if> on # liga modo monitor (precisa root)
sabia scan <if> --out captura.pcap # varre canais, mostra APs/clientes
sabia ui # abre a interface gráfica local
sabia update # verifica atualização
sabia update --apply # baixa e aplica a atualização
```
Capturar quadros 802.11 exige `CAP_NET_RAW`/`CAP_NET_ADMIN` — normalmente
root (`sudo sabia ...`).
## Dependências externas
`iw` e `ip` (iproute2) para controlar modo monitor e canal — é uma ponte
deliberada até existir uma implementação nl80211 nativa. A captura em si
(AF_PACKET) e todo o parsing de quadro são Rust puro, sem libpcap.
## Hardware testado
Adaptador USB RTL8812AU (driver `rtw88_8812au`), suporta modo monitor e
injeção.
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "sabia-cli"
description = "sabia: CLI de auditoria e monitoramento de redes Wi-Fi"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
[[bin]]
name = "sabia"
path = "src/main.rs"
[dependencies]
sabia-core = { path = "../sabia-core" }
clap = { version = "4", features = ["derive"] }
serde_json = "1"
ctrlc = "3"
+216
View File
@@ -0,0 +1,216 @@
mod web;
use clap::{Parser, Subcommand};
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[derive(Parser)]
#[command(name = "sabia", about = "Sabiá — auditoria e monitoramento de redes Wi-Fi", version)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Lista interfaces de rede e mostra quais são Wi-Fi.
Iface {
#[command(subcommand)]
action: IfaceAction,
},
/// Varre o ar na interface (já em modo monitor) e mostra APs/clientes vistos.
Scan {
iface: String,
/// Canal fixo; sem isso, pula entre todos os canais suportados.
#[arg(long)]
channel: Option<u32>,
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
#[arg(long)]
seconds: Option<u64>,
/// Salva tudo que foi capturado em um .pcap (inclui handshakes).
#[arg(long)]
out: Option<PathBuf>,
},
/// Verifica e aplica atualizações do próprio sabia.
Update {
#[arg(long)]
apply: bool,
},
/// Confere a integridade da trilha de auditoria.
AuditVerify { path: PathBuf },
/// Abre a interface gráfica (servidor local + navegador).
Ui {
#[arg(long, default_value = "4337")]
port: u16,
},
}
#[derive(Subcommand)]
enum IfaceAction {
List,
/// Liga/desliga modo monitor numa interface Wi-Fi.
Monitor { iface: String, #[arg(value_enum)] state: OnOff },
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum OnOff {
On,
Off,
}
fn audit_path() -> PathBuf {
PathBuf::from(".sabia-audit.log")
}
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("erro: {e}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> sabia_core::Result<()> {
match cli.cmd {
Cmd::Iface { action } => match action {
IfaceAction::List => {
for i in iface::list_all()? {
let tag = if i.phy.is_some() { "wifi" } else { "—" };
println!(
"{:<16} {:<18} {:<6} {:<10} {}",
i.name,
i.mac,
tag,
format!("{:?}", i.mode),
if i.up { "up" } else { "down" }
);
}
}
IfaceAction::Monitor { iface: name, state } => {
let enable = matches!(state, OnOff::On);
iface::get(&name)?; // valida que é Wi-Fi antes de tocar nela
netlink::set_monitor_mode(&name, enable)?;
let mut log = AuditLog::open(audit_path())?;
log.record(
"monitor_mode",
&name,
&format!("{{\"enable\": {enable}}}"),
)?;
println!("{name}: modo monitor {}", if enable { "ativado" } else { "desativado" });
}
},
Cmd::Scan { iface: name, channel, seconds, out } => {
let info = iface::get(&name)?;
if info.mode != iface::Mode::Monitor {
eprintln!("aviso: {name} não está em modo monitor (sabia iface monitor {name} on)");
}
let mut log = AuditLog::open(audit_path())?;
log.record("scan_start", &name, &format!("{{\"channel\": {channel:?}}}"))?;
let cap = RawCapture::open(&name)?;
cap.set_read_timeout(Duration::from_millis(500))?;
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || r.store(false, Ordering::SeqCst))
.expect("falha ao instalar handler de Ctrl+C");
let mut table = ScanTable::new();
let mut writer = match &out {
Some(p) => Some(PcapWriter::create(p)?),
None => None,
};
let channels: Vec<u32> = match channel {
Some(c) => vec![c],
None => netlink::supported_channels(&name).unwrap_or_default(),
};
let mut chan_idx = 0usize;
let mut last_hop = std::time::Instant::now();
let deadline = seconds.map(|s| std::time::Instant::now() + Duration::from_secs(s));
let mut buf = [0u8; 65535];
println!("capturando em {name}... (Ctrl+C para parar)");
while running.load(Ordering::SeqCst) {
if let Some(d) = deadline {
if std::time::Instant::now() >= d {
break;
}
}
if channel.is_none() && !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
chan_idx = (chan_idx + 1) % channels.len();
let _ = netlink::set_channel(&name, channels[chan_idx]);
last_hop = std::time::Instant::now();
}
match cap.recv(&mut buf)? {
Some(n) => {
let frame = &buf[..n];
let had_eapol = table.ingest(frame);
if had_eapol {
eprintln!("[!] handshake EAPOL visto");
}
if let Some(w) = writer.as_mut() {
w.write_frame(frame)?;
}
}
None => continue, // timeout, sem quadro
}
}
if let Some(w) = writer.as_mut() {
w.flush()?;
}
log.record("scan_stop", &name, &format!("{{\"aps_found\": {}}}", table.access_points().len()))?;
println!("\n{:<18} {:<24} {:<8} {:<8} {:<6} {}", "BSSID", "SSID", "CANAL", "SINAL", "SEG", "CLIENTES");
for ap in table.access_points() {
println!(
"{:<18} {:<24} {:<8} {:<8} {:<6} {}",
ap.bssid,
ap.ssid.unwrap_or_default(),
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
ap.last_signal_dbm.map(|s| format!("{s}dBm")).unwrap_or_default(),
ap.security.unwrap_or_default(),
ap.clients.len()
);
}
}
Cmd::Update { apply } => {
let current = env!("CARGO_PKG_VERSION");
if apply {
let exe = std::env::current_exe()?;
let version = sabia_core::update::apply(&exe)?;
println!("atualizado para {version}");
} else {
let status = sabia_core::update::check(current)?;
if status.update_available {
println!(
"atualização disponível: {} -> {} (rode `sabia update --apply`)",
status.current_version,
status.latest_version.unwrap_or_default()
);
} else {
println!("já na última versão ({})", status.current_version);
}
}
}
Cmd::AuditVerify { path } => match sabia_core::audit::AuditLog::verify(&path)? {
None => println!("cadeia de auditoria íntegra"),
Some(idx) => println!("cadeia ROMPIDA na entrada {idx}"),
},
Cmd::Ui { port } => {
web::serve(port)?;
}
}
Ok(())
}
+157
View File
@@ -0,0 +1,157 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sabiá</title>
<style>
:root {
--bg: #0f1115; --panel: #171a21; --line: #262b35; --text: #e7e9ee;
--muted: #8b93a3; --accent: #3ddc97; --warn: #e2b23d; --danger: #e2543d;
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--text); font: 14px/1.4 system-ui, sans-serif; }
header { display:flex; align-items:center; gap:.75rem; padding:1rem 1.25rem; border-bottom:1px solid var(--line); }
header h1 { font-size:1.1rem; margin:0; font-weight:600; letter-spacing:.02em; }
header h1 span { color:var(--accent); }
main { padding:1.25rem; max-width:1080px; margin:0 auto; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:1rem 1.1rem; margin-bottom:1.1rem; }
.row { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; }
select, button { background:#1e222b; color:var(--text); border:1px solid var(--line); border-radius:7px; padding:.5rem .7rem; font-size:.9rem; }
button { cursor:pointer; }
button.primary { background:var(--accent); color:#06231a; border-color:var(--accent); font-weight:600; }
button.danger { background:var(--danger); color:#2a0b06; border-color:var(--danger); }
button:disabled { opacity:.45; cursor:not-allowed; }
.pill { font-size:.75rem; padding:.15rem .55rem; border-radius:999px; border:1px solid var(--line); color:var(--muted); }
.pill.on { color:var(--accent); border-color:var(--accent); }
table { width:100%; border-collapse:collapse; margin-top:.5rem; }
th, td { text-align:left; padding:.45rem .5rem; border-bottom:1px solid var(--line); font-size:.85rem; }
th { color:var(--muted); font-weight:500; text-transform:uppercase; font-size:.7rem; letter-spacing:.04em; }
td.sec-open { color:var(--danger); } td.sec-wep { color:var(--warn); } td.sec-wpa, td.sec-wpa2, td.sec-wpa3 { color:var(--accent); }
.muted { color:var(--muted); }
.warning { background:#241a10; border:1px solid #4a3414; color:#e2b23d; border-radius:8px; padding:.6rem .8rem; font-size:.82rem; margin-bottom:1rem; }
</style>
</head>
<body>
<header>
<h1>Sabi<span>á</span></h1>
<span class="muted">auditoria de redes Wi-Fi</span>
</header>
<main>
<div class="warning">Use somente em redes próprias ou com autorização explícita do responsável. Toda ativação de modo monitor e sessão de captura é registrada na trilha de auditoria local.</div>
<div class="panel">
<div class="row">
<label class="muted">Interface</label>
<select id="iface"></select>
<span id="mode" class="pill">—</span>
<button id="btnMonitor">ativar modo monitor</button>
<button id="btnScan" class="primary" disabled>iniciar captura</button>
</div>
</div>
<div class="panel">
<div class="row" style="justify-content:space-between">
<strong>Redes vistas</strong>
<span id="count" class="muted">0</span>
</div>
<table>
<thead><tr><th>BSSID</th><th>SSID</th><th>Canal</th><th>Sinal</th><th>Segurança</th><th>Clientes</th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
</main>
<script>
const params = new URLSearchParams(location.search);
const token = params.get('token');
let currentIface = null;
let monitorOn = false;
let scanning = false;
async function api(path, opts) {
const url = new URL(path, location.origin);
url.searchParams.set('token', token);
const r = await fetch(url, opts);
return r.json();
}
async function loadIfaces() {
const list = await api('/api/ifaces');
const sel = document.getElementById('iface');
sel.innerHTML = '';
for (const i of list) {
const opt = document.createElement('option');
opt.value = i.name;
opt.textContent = `${i.name} (${i.mac})`;
opt.dataset.mode = i.mode;
sel.appendChild(opt);
}
if (list.length) onIfaceChange();
}
function onIfaceChange() {
const sel = document.getElementById('iface');
const opt = sel.selectedOptions[0];
currentIface = sel.value;
monitorOn = opt && opt.dataset.mode === 'Monitor';
refreshModeUi();
}
function refreshModeUi() {
const pill = document.getElementById('mode');
pill.textContent = monitorOn ? 'modo monitor' : 'modo managed';
pill.className = 'pill' + (monitorOn ? ' on' : '');
document.getElementById('btnMonitor').textContent = monitorOn ? 'desativar modo monitor' : 'ativar modo monitor';
document.getElementById('btnScan').disabled = !monitorOn;
}
document.getElementById('iface').addEventListener('change', onIfaceChange);
document.getElementById('btnMonitor').addEventListener('click', async () => {
await api(`/api/monitor&iface=${encodeURIComponent(currentIface)}&enable=${!monitorOn}`.replace('&', '?'), { method: 'POST' });
monitorOn = !monitorOn;
refreshModeUi();
});
document.getElementById('btnScan').addEventListener('click', async () => {
const btn = document.getElementById('btnScan');
if (!scanning) {
await api(`/api/scan/start?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
scanning = true;
btn.textContent = 'parar captura';
btn.classList.add('danger');
} else {
await api(`/api/scan/stop?iface=${encodeURIComponent(currentIface)}`, { method: 'POST' });
scanning = false;
btn.textContent = 'iniciar captura';
btn.classList.remove('danger');
}
});
function secClass(sec) {
if (!sec) return '';
return 'sec-' + sec.toLowerCase().replace('á', 'a');
}
async function poll() {
if (scanning && currentIface) {
const aps = await api(`/api/scan?iface=${encodeURIComponent(currentIface)}`);
const rows = document.getElementById('rows');
rows.innerHTML = '';
document.getElementById('count').textContent = aps.length;
for (const ap of aps) {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${ap.bssid}</td><td>${ap.ssid || '<em class="muted">oculto</em>'}</td>` +
`<td>${ap.channel ?? '—'}</td><td>${ap.last_signal_dbm != null ? ap.last_signal_dbm + ' dBm' : '—'}</td>` +
`<td class="${secClass(ap.security)}">${ap.security || '—'}</td><td>${ap.clients.length}</td>`;
rows.appendChild(tr);
}
}
setTimeout(poll, 1000);
}
loadIfaces();
poll();
</script>
</body>
</html>
+222
View File
@@ -0,0 +1,222 @@
//! Servidor local da interface: a mesma filosofia do Guará (`guara ui`) —
//! uma única página HTML/JS servida em `127.0.0.1`, protegida por um token
//! gerado na hora, falando com o núcleo por uma API JSON simples. A mesma
//! página é o que o shell desktop (Tauri, em `apps/gui`) vai carregar depois.
use sabia_core::{audit::AuditLog, capture::RawCapture, iface, netlink, scan::ScanTable};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
const INDEX_HTML: &str = include_str!("index.html");
struct Session {
table: Arc<Mutex<ScanTable>>,
running: Arc<AtomicBool>,
}
#[derive(Default)]
struct AppState {
sessions: Mutex<HashMap<String, Session>>,
}
pub fn serve(port: u16) -> sabia_core::Result<()> {
let token = gen_token();
let listener = TcpListener::bind(("127.0.0.1", port))?;
let state = Arc::new(AppState::default());
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let state = state.clone();
let token = token.clone();
thread::spawn(move || {
let _ = handle(stream, &state, &token);
});
}
Ok(())
}
fn gen_token() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
format!("{nanos:x}")
}
fn open_browser(url: &str) -> std::io::Result<()> {
std::process::Command::new("xdg-open").arg(url).spawn().map(|_| ())
}
struct Request {
method: String,
path: String,
query: HashMap<String, String>,
token: Option<String>,
}
fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or("GET").to_string();
let full_path = parts.next().unwrap_or("/").to_string();
// consome o resto dos headers
loop {
let mut l = String::new();
if reader.read_line(&mut l)? == 0 || l == "\r\n" {
break;
}
}
let (path, query_str) = match full_path.split_once('?') {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (full_path, String::new()),
};
let mut query = HashMap::new();
for pair in query_str.split('&') {
if let Some((k, v)) = pair.split_once('=') {
query.insert(urldecode(k), urldecode(v));
}
}
let token = query.get("token").cloned();
Ok(Request { method, path, query, token })
}
fn urldecode(s: &str) -> String {
s.replace('+', " ").replace("%3A", ":").replace("%2F", "/").replace("%20", " ")
}
fn handle(mut stream: TcpStream, state: &Arc<AppState>, expected_token: &str) -> std::io::Result<()> {
let req = parse_request(&mut stream)?;
if req.path == "/" {
if req.token.as_deref() != Some(expected_token) {
return respond(&mut stream, 401, "text/plain", b"token invalido");
}
return respond(&mut stream, 200, "text/html; charset=utf-8", INDEX_HTML.as_bytes());
}
if !req.path.starts_with("/api/") {
return respond(&mut stream, 404, "text/plain", b"not found");
}
if req.token.as_deref() != Some(expected_token) {
return respond(&mut stream, 401, "application/json", b"{\"error\":\"token invalido\"}");
}
let body = api(state, &req);
respond(&mut stream, 200, "application/json", body.as_bytes())
}
fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::io::Result<()> {
let status = match code {
200 => "OK",
401 => "Unauthorized",
404 => "Not Found",
_ => "Error",
};
write!(stream, "HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len())?;
stream.write_all(body)?;
Ok(())
}
fn api(state: &Arc<AppState>, req: &Request) -> String {
match (req.method.as_str(), req.path.as_str()) {
("GET", "/api/ifaces") => {
let list = iface::list_wireless().unwrap_or_default();
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
}
("POST", "/api/monitor") => {
let name = req.query.get("iface").cloned().unwrap_or_default();
let enable = req.query.get("enable").map(|v| v == "true").unwrap_or(false);
match netlink::set_monitor_mode(&name, enable) {
Ok(()) => {
if let Ok(mut log) = AuditLog::open(".sabia-audit.log") {
let _ = log.record("monitor_mode", &name, &format!("{{\"enable\":{enable}}}"));
}
"{\"ok\":true}".into()
}
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
}
}
("POST", "/api/scan/start") => {
let name = req.query.get("iface").cloned().unwrap_or_default();
start_scan(state, name)
}
("POST", "/api/scan/stop") => {
let name = req.query.get("iface").cloned().unwrap_or_default();
stop_scan(state, &name);
"{\"ok\":true}".into()
}
("GET", "/api/scan") => {
let name = req.query.get("iface").cloned().unwrap_or_default();
let sessions = state.sessions.lock().unwrap();
match sessions.get(&name) {
Some(s) => {
let table = s.table.lock().unwrap();
serde_json::to_string(&table.access_points()).unwrap_or_else(|_| "[]".into())
}
None => "[]".into(),
}
}
_ => "{\"error\":\"rota desconhecida\"}".into(),
}
}
fn start_scan(state: &Arc<AppState>, name: String) -> String {
let mut sessions = state.sessions.lock().unwrap();
if sessions.contains_key(&name) {
return "{\"ok\":true,\"already_running\":true}".into();
}
let table = Arc::new(Mutex::new(ScanTable::new()));
let running = Arc::new(AtomicBool::new(true));
let t = table.clone();
let r = running.clone();
let iface_name = name.clone();
thread::spawn(move || {
let cap = match RawCapture::open(&iface_name) {
Ok(c) => c,
Err(e) => {
eprintln!("falha ao abrir captura em {iface_name}: {e}");
return;
}
};
let _ = cap.set_read_timeout(Duration::from_millis(500));
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
let mut idx = 0usize;
let mut last_hop = std::time::Instant::now();
let mut buf = [0u8; 65535];
while r.load(Ordering::SeqCst) {
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
idx = (idx + 1) % channels.len();
let _ = netlink::set_channel(&iface_name, channels[idx]);
last_hop = std::time::Instant::now();
}
if let Ok(Some(n)) = cap.recv(&mut buf) {
let mut table = t.lock().unwrap();
table.ingest(&buf[..n]);
}
}
});
sessions.insert(name, Session { table, running });
"{\"ok\":true}".into()
}
fn stop_scan(state: &Arc<AppState>, name: &str) {
let mut sessions = state.sessions.lock().unwrap();
if let Some(s) = sessions.remove(name) {
s.running.store(false, Ordering::SeqCst);
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "sabia-core"
description = "Sabiá: núcleo de captura, análise e auditoria de redes Wi-Fi"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
[dependencies]
libc = "0.2"
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3", features = ["formatting", "macros"] }
sha2 = "0.10"
[target.'cfg(not(target_os = "linux"))'.dependencies]
# captura crua (AF_PACKET) só existe no Linux; outras plataformas compilam
# a crate mas os módulos de captura ficam indisponíveis em tempo de execução.
+114
View File
@@ -0,0 +1,114 @@
//! Trilha de auditoria encadeada por hash de toda ação sensível (ligar
//! modo monitor, iniciar captura, trocar canal, etc.) — mesmo princípio do
//! módulo `audit` do Guará: cada evento guarda o hash do anterior, então
//! qualquer edição retroativa do log quebra a cadeia e é detectável.
//!
//! Isso existe para que o uso do Sabiá em um engajamento de pentest tenha
//! evidência de o quê foi feito, quando e contra qual rede/interface —
//! não para decidir sozinho se uma ação é autorizada (isso é sempre do
//! operador).
use crate::error::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: String,
pub action: String,
pub target: String,
pub detail: String,
pub prev_hash: String,
pub hash: String,
}
pub struct AuditLog {
path: std::path::PathBuf,
last_hash: String,
}
const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000";
impl AuditLog {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let last_hash = if path.exists() {
let file = OpenOptions::new().read(true).open(&path)?;
let reader = BufReader::new(file);
let mut last = GENESIS.to_string();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<AuditEntry>(&line) {
last = entry.hash;
}
}
last
} else {
GENESIS.to_string()
};
Ok(Self { path, last_hash })
}
pub fn record(&mut self, action: &str, target: &str, detail: &str) -> Result<AuditEntry> {
let timestamp = OffsetDateTime::now_utc().to_string();
let mut hasher = Sha256::new();
hasher.update(self.last_hash.as_bytes());
hasher.update(timestamp.as_bytes());
hasher.update(action.as_bytes());
hasher.update(target.as_bytes());
hasher.update(detail.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let entry = AuditEntry {
timestamp,
action: action.to_string(),
target: target.to_string(),
detail: detail.to_string(),
prev_hash: self.last_hash.clone(),
hash: hash.clone(),
};
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
writeln!(file, "{}", serde_json::to_string(&entry)?)?;
self.last_hash = hash;
Ok(entry)
}
/// Verifica a integridade de toda a cadeia; retorna o índice da
/// primeira entrada inválida, se houver.
pub fn verify(path: impl AsRef<Path>) -> Result<Option<usize>> {
let file = OpenOptions::new().read(true).open(path)?;
let reader = BufReader::new(file);
let mut prev = GENESIS.to_string();
for (i, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let entry: AuditEntry = serde_json::from_str(&line)?;
if entry.prev_hash != prev {
return Ok(Some(i));
}
let mut hasher = Sha256::new();
hasher.update(entry.prev_hash.as_bytes());
hasher.update(entry.timestamp.as_bytes());
hasher.update(entry.action.as_bytes());
hasher.update(entry.target.as_bytes());
hasher.update(entry.detail.as_bytes());
let expected = format!("{:x}", hasher.finalize());
if expected != entry.hash {
return Ok(Some(i));
}
prev = entry.hash;
}
Ok(None)
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Captura crua de quadros 802.11 via `AF_PACKET`, sem depender de libpcap.
//!
//! A interface já precisa estar em modo monitor (ver [`crate::netlink`]).
//! Isso só funciona em Linux — é exatamente o que o libpcap faz por baixo
//! quando usado em modo monitor, então reimplementar aqui é pouco código e
//! remove uma dependência de sistema.
#![cfg(target_os = "linux")]
use crate::error::{Result, SabiaError};
use std::ffi::CString;
use std::io;
use std::mem;
use std::os::fd::{AsRawFd, RawFd};
use std::time::Duration;
const ETH_P_ALL: u16 = 0x0003;
pub struct RawCapture {
fd: RawFd,
}
impl RawCapture {
/// Abre um socket raw ligado à interface `iface` (já em modo monitor).
/// Exige `CAP_NET_RAW` (tipicamente root).
pub fn open(iface: &str) -> Result<Self> {
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW, ETH_P_ALL as i32) };
if fd < 0 {
let err = io::Error::last_os_error();
return Err(if err.raw_os_error() == Some(libc::EPERM) {
SabiaError::PermissionDenied
} else {
SabiaError::Io(err)
});
}
let ifindex = if_index(iface)?;
let mut sll: libc::sockaddr_ll = unsafe { mem::zeroed() };
sll.sll_family = libc::AF_PACKET as u16;
sll.sll_protocol = (ETH_P_ALL as u16).to_be();
sll.sll_ifindex = ifindex;
let ret = unsafe {
libc::bind(
fd,
&sll as *const libc::sockaddr_ll as *const libc::sockaddr,
mem::size_of::<libc::sockaddr_ll>() as u32,
)
};
if ret < 0 {
let err = io::Error::last_os_error();
unsafe { libc::close(fd) };
return Err(SabiaError::Io(err));
}
Ok(Self { fd })
}
/// Define um timeout de recepção, para o loop de captura poder checar
/// periodicamente um sinal de parada sem travar para sempre em `recv`.
pub fn set_read_timeout(&self, timeout: Duration) -> Result<()> {
let tv = libc::timeval {
tv_sec: timeout.as_secs() as libc::time_t,
tv_usec: timeout.subsec_micros() as libc::suseconds_t,
};
let ret = unsafe {
libc::setsockopt(
self.fd,
libc::SOL_SOCKET,
libc::SO_RCVTIMEO,
&tv as *const _ as *const libc::c_void,
mem::size_of::<libc::timeval>() as u32,
)
};
if ret < 0 {
return Err(SabiaError::Io(io::Error::last_os_error()));
}
Ok(())
}
/// Lê um quadro. Retorna `Ok(None)` em timeout (ver `set_read_timeout`).
pub fn recv(&self, buf: &mut [u8]) -> Result<Option<usize>> {
let n = unsafe { libc::recv(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
if n < 0 {
let err = io::Error::last_os_error();
if matches!(err.raw_os_error(), Some(libc::EAGAIN)) {
return Ok(None);
}
return Err(SabiaError::Io(err));
}
Ok(Some(n as usize))
}
}
impl AsRawFd for RawCapture {
fn as_raw_fd(&self) -> RawFd {
self.fd
}
}
impl Drop for RawCapture {
fn drop(&mut self) {
unsafe { libc::close(self.fd) };
}
}
fn if_index(name: &str) -> Result<i32> {
let cname = CString::new(name).map_err(|_| SabiaError::InterfaceNotFound(name.to_string()))?;
let idx = unsafe { libc::if_nametoindex(cname.as_ptr()) };
if idx == 0 {
return Err(SabiaError::InterfaceNotFound(name.to_string()));
}
Ok(idx as i32)
}
/// Garante, de forma best-effort, que o processo tem privilégio para abrir
/// sockets raw — útil para dar um erro claro antes de tentar o resto.
pub fn check_capabilities() -> bool {
let euid = unsafe { libc::geteuid() };
euid == 0
}
+183
View File
@@ -0,0 +1,183 @@
//! Parser mínimo de quadros 802.11 de gerenciamento (beacon, probe
//! request/response) e detecção de EAPOL (handshake WPA/WPA2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameKind {
Beacon,
ProbeRequest,
ProbeResponse,
AssociationRequest,
Deauth,
Disassoc,
Data,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Security {
Open,
Wep,
Wpa,
Wpa2,
Wpa3,
}
#[derive(Debug, Clone, Default)]
pub struct ManagementInfo {
pub ssid: Option<String>,
pub security: Option<Security>,
pub channel_ie: Option<u32>,
}
pub fn mac_to_string(mac: &[u8]) -> String {
format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
}
/// Quadro 802.11 já sem o cabeçalho radiotap.
pub struct Frame<'a> {
pub kind: FrameKind,
pub bssid: Option<[u8; 6]>,
pub src: Option<[u8; 6]>,
pub dst: Option<[u8; 6]>,
pub body: &'a [u8],
/// offset, dentro de `body` completo original, de onde começam os
/// "information elements" (só preenchido para beacon/probe response).
ie_offset: usize,
}
pub fn parse(buf: &[u8]) -> Option<Frame<'_>> {
if buf.len() < 10 {
return None;
}
let fc = u16::from_le_bytes([buf[0], buf[1]]);
let frame_type = (fc >> 2) & 0b11;
let subtype = (fc >> 4) & 0b1111;
let kind = match (frame_type, subtype) {
(0, 8) => FrameKind::Beacon,
(0, 4) => FrameKind::ProbeRequest,
(0, 5) => FrameKind::ProbeResponse,
(0, 0) => FrameKind::AssociationRequest,
(0, 12) => FrameKind::Deauth,
(0, 10) => FrameKind::Disassoc,
(2, _) => FrameKind::Data,
_ => FrameKind::Other,
};
if buf.len() < 24 {
// sem addr1/2/3 completos (comum em alguns quadros de controle)
return Some(Frame { kind, bssid: None, src: None, dst: None, body: buf, ie_offset: 0 });
}
let addr1: [u8; 6] = buf[4..10].try_into().ok()?;
let addr2: [u8; 6] = buf[10..16].try_into().ok()?;
let addr3: [u8; 6] = buf[16..22].try_into().ok()?;
let (dst, src, bssid) = (addr1, addr2, addr3);
// Para beacon/probe response, corpo fixo de 12 bytes (timestamp +
// interval + capability) antecede os information elements.
let ie_offset = if matches!(kind, FrameKind::Beacon | FrameKind::ProbeResponse) { 24 + 12 } else { 24 };
Some(Frame { kind, bssid: Some(bssid), src: Some(src), dst: Some(dst), body: buf, ie_offset })
}
impl<'a> Frame<'a> {
pub fn bssid_str(&self) -> Option<String> {
self.bssid.map(|m| mac_to_string(&m))
}
pub fn src_str(&self) -> Option<String> {
self.src.map(|m| mac_to_string(&m))
}
/// Faz parsing dos "information elements" (TLV) de um beacon/probe
/// response, extraindo SSID, canal e indício de cifragem.
pub fn management_info(&self) -> ManagementInfo {
let mut info = ManagementInfo::default();
if self.ie_offset == 0 || self.ie_offset >= self.body.len() {
return info;
}
let privacy = {
// bit "Privacy" no campo capability, 2 bytes antes do fim do
// cabeçalho fixo (offset 24+10, 2 bytes).
if self.body.len() >= 24 + 12 {
let cap = u16::from_le_bytes([self.body[24 + 10], self.body[24 + 11]]);
cap & 0x0010 != 0
} else {
false
}
};
let mut rsn_present = false;
let mut wpa1_present = false;
let ies = &self.body[self.ie_offset..];
let mut i = 0;
while i + 2 <= ies.len() {
let tag = ies[i];
let len = ies[i + 1] as usize;
let start = i + 2;
if start + len > ies.len() {
break;
}
let val = &ies[start..start + len];
match tag {
0 => {
// SSID; pode ser "oculto" (len 0 ou só zeros)
if len > 0 && val.iter().any(|&b| b != 0) {
info.ssid = Some(String::from_utf8_lossy(val).into_owned());
} else {
info.ssid = Some(String::new());
}
}
3 => {
if len == 1 {
info.channel_ie = Some(val[0] as u32);
}
}
48 => rsn_present = true, // RSN IE = WPA2/WPA3
221 => {
// vendor-specific; OUI 00:50:F2 tipo 1 = WPA1
if len >= 4 && val[0..3] == [0x00, 0x50, 0xF2] && val[3] == 1 {
wpa1_present = true;
}
}
_ => {}
}
i = start + len;
}
info.security = Some(if rsn_present {
Security::Wpa2
} else if wpa1_present {
Security::Wpa
} else if privacy {
Security::Wep
} else {
Security::Open
});
info
}
}
/// Detecta se um quadro de dados carrega EAPOL (handshake 4-way), olhando o
/// cabeçalho LLC/SNAP (ethertype 0x888E) após o cabeçalho 802.11 de dados.
pub fn is_eapol(body: &[u8]) -> bool {
// Cabeçalho de dados pode ter 24 ou 26 bytes (QoS); LLC/SNAP tem 8
// bytes (AA AA 03 00 00 00) + ethertype de 2 bytes.
for hdr_len in [24usize, 26] {
if body.len() < hdr_len + 8 {
continue;
}
let llc = &body[hdr_len..hdr_len + 8];
if llc[0] == 0xAA && llc[1] == 0xAA && llc[2] == 0x03 {
let ethertype = u16::from_be_bytes([llc[6], llc[7]]);
if ethertype == 0x888E {
return true;
}
}
}
false
}
+27
View File
@@ -0,0 +1,27 @@
use std::io;
#[derive(Debug, thiserror::Error)]
pub enum SabiaError {
#[error("interface `{0}` não encontrada")]
InterfaceNotFound(String),
#[error("interface `{0}` não é uma interface Wi-Fi (sem phy80211)")]
NotWireless(String),
#[error("falha ao executar `{cmd}`: {detail}")]
CommandFailed { cmd: String, detail: String },
#[error("permissão insuficiente — operações de captura e modo monitor exigem root (CAP_NET_ADMIN/CAP_NET_RAW)")]
PermissionDenied,
#[error("captura crua só é suportada no Linux (AF_PACKET)")]
UnsupportedPlatform,
#[error("erro de E/S: {0}")]
Io(#[from] io::Error),
#[error("erro ao (de)serializar: {0}")]
Serde(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, SabiaError>;
+110
View File
@@ -0,0 +1,110 @@
//! Descoberta de interfaces de rede e identificação das que são Wi-Fi.
//!
//! Não depende de `iw`: lê direto de `/sys/class/net`, do mesmo jeito que o
//! próprio `iw`/`ip` fazem por baixo.
use crate::error::{Result, SabiaError};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, serde::Serialize)]
pub struct Interface {
pub name: String,
pub mac: String,
/// Caminho do phy (ex.: `phy0`), presente só em interfaces 802.11.
pub phy: Option<String>,
pub mode: Mode,
pub up: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum Mode {
Managed,
Monitor,
Unknown,
}
fn net_root() -> &'static Path {
Path::new("/sys/class/net")
}
/// Lista todas as interfaces de rede, Wi-Fi ou não (chamador filtra por `phy`).
pub fn list_all() -> Result<Vec<Interface>> {
let mut out = Vec::new();
for entry in fs::read_dir(net_root())? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
if name == "lo" {
continue;
}
out.push(read_interface(&name)?);
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
/// Lista apenas interfaces com um `phy80211` (ou seja, rádios Wi-Fi).
pub fn list_wireless() -> Result<Vec<Interface>> {
Ok(list_all()?.into_iter().filter(|i| i.phy.is_some()).collect())
}
pub fn get(name: &str) -> Result<Interface> {
let iface = read_interface(name)?;
if iface.phy.is_none() {
return Err(SabiaError::NotWireless(name.to_string()));
}
Ok(iface)
}
fn read_interface(name: &str) -> Result<Interface> {
let base: PathBuf = net_root().join(name);
if !base.exists() {
return Err(SabiaError::InterfaceNotFound(name.to_string()));
}
let mac = fs::read_to_string(base.join("address"))
.map(|s| s.trim().to_string())
.unwrap_or_default();
let up = fs::read_to_string(base.join("operstate"))
.map(|s| s.trim() == "up")
.unwrap_or(false);
let phy80211 = base.join("phy80211");
let phy = if phy80211.is_dir() {
fs::read_link(&phy80211)
.ok()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()))
.or_else(|| {
// em alguns kernels phy80211 é um diretório real, não symlink;
// o nome do phy então vem do subdiretório "phyNN" dentro dele.
fs::read_dir(&phy80211)
.ok()
.and_then(|mut rd| rd.next())
.and_then(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
})
} else {
None
};
let mode = if phy.is_some() {
read_wireless_mode(name)
} else {
Mode::Unknown
};
Ok(Interface { name: name.to_string(), mac, phy, mode, up })
}
/// Lê o modo (`managed`/`monitor`) via `/sys/class/net/<if>/type` + `uevent`
/// não é suficiente (type é sempre ARPHRD_ETHER para Wi-Fi); a forma estável
/// sem libnl é delegar a leitura real ao `iw dev <if> info` quando disponível,
/// mantendo aqui um melhor-esforço local para não depender de root.
fn read_wireless_mode(name: &str) -> Mode {
match crate::netlink::iw_dev_type(name) {
Some(Mode::Monitor) => Mode::Monitor,
Some(Mode::Managed) => Mode::Managed,
_ => Mode::Unknown,
}
}
+19
View File
@@ -0,0 +1,19 @@
//! `sabia-core`: tudo que fala com o rádio Wi-Fi, interpreta quadros 802.11
//! e mantém a trilha de auditoria. A CLI e a interface gráfica são só
//! consumidoras disto — a parte complexa (netlink, radiotap, parsing de
//! frame) fica inteira aqui dentro.
pub mod audit;
pub mod dot11;
pub mod error;
pub mod iface;
pub mod netlink;
pub mod pcapwriter;
pub mod radiotap;
pub mod scan;
pub mod update;
#[cfg(target_os = "linux")]
pub mod capture;
pub use error::{Result, SabiaError};
+95
View File
@@ -0,0 +1,95 @@
//! Controle de modo (managed/monitor) e canal via `iw`.
//!
//! Isso é uma ponte deliberada: o protocolo real é nl80211 por netlink, e
//! trocar este módulo por uma implementação nativa (sem depender do binário
//! `iw` instalado) é o próximo passo natural. Por ora, `iw` é amplamente
//! disponível, testado e evita reimplementar genl/nl80211 antes de validar
//! o resto do pipeline com o adaptador real.
use crate::error::{Result, SabiaError};
use crate::iface::Mode;
use std::process::Command;
fn run(cmd: &str, args: &[&str]) -> Result<String> {
let out = Command::new(cmd).args(args).output().map_err(|e| SabiaError::CommandFailed {
cmd: format!("{cmd} {}", args.join(" ")),
detail: e.to_string(),
})?;
if !out.status.success() {
let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
let detail = if detail.is_empty() {
String::from_utf8_lossy(&out.stdout).trim().to_string()
} else {
detail
};
if detail.contains("Operation not permitted") || detail.contains("nl80211 not found") {
return Err(SabiaError::PermissionDenied);
}
return Err(SabiaError::CommandFailed {
cmd: format!("{cmd} {}", args.join(" ")),
detail,
});
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn iw_dev_type(iface: &str) -> Option<Mode> {
let out = run("iw", &["dev", iface, "info"]).ok()?;
for line in out.lines() {
let line = line.trim();
if let Some(t) = line.strip_prefix("type ") {
return Some(match t.trim() {
"monitor" => Mode::Monitor,
"managed" => Mode::Managed,
_ => Mode::Unknown,
});
}
}
None
}
/// Lista (canal, frequência) suportados pelo phy da interface.
pub fn supported_channels(iface: &str) -> Result<Vec<u32>> {
let out = run("iw", &["dev", iface, "info"])?;
let phy = out
.lines()
.find_map(|l| l.trim().strip_prefix("wiphy "))
.map(|n| format!("phy{n}"))
.ok_or_else(|| SabiaError::CommandFailed {
cmd: "iw dev info".into(),
detail: "wiphy não encontrado na saída".into(),
})?;
let info = run("iw", &["phy", &phy, "info"])?;
let mut channels = Vec::new();
for line in info.lines() {
let line = line.trim();
// formato: "* 2412 MHz [1] (20.0 dBm)"
if let Some(rest) = line.strip_prefix("* ") {
if let Some(start) = rest.find('[') {
if let Some(end) = rest[start..].find(']') {
if let Ok(ch) = rest[start + 1..start + end].parse::<u32>() {
channels.push(ch);
}
}
}
}
}
channels.sort_unstable();
channels.dedup();
Ok(channels)
}
/// Põe a interface em modo monitor. Requer `CAP_NET_ADMIN` (normalmente root).
pub fn set_monitor_mode(iface: &str, enable: bool) -> Result<()> {
run("ip", &["link", "set", iface, "down"])?;
let target = if enable { "monitor" } else { "managed" };
run("iw", &["dev", iface, "set", "type", target])?;
run("ip", &["link", "set", iface, "up"])?;
Ok(())
}
/// Troca o canal atual da interface (precisa já estar em modo monitor).
pub fn set_channel(iface: &str, channel: u32) -> Result<()> {
run("iw", &["dev", iface, "set", "channel", &channel.to_string()])?;
Ok(())
}
+47
View File
@@ -0,0 +1,47 @@
//! Escritor de arquivos `.pcap` (formato clássico, link-type 127 =
//! IEEE802_11_RADIOTAP) para exportar capturas — compatível com
//! Wireshark, aircrack-ng e qualquer outra ferramenta do ecossistema.
use crate::error::Result;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const LINKTYPE_IEEE802_11_RADIOTAP: u32 = 127;
pub struct PcapWriter {
out: BufWriter<File>,
}
impl PcapWriter {
pub fn create(path: impl AsRef<Path>) -> Result<Self> {
let file = File::create(path)?;
let mut out = BufWriter::new(file);
// Cabeçalho global do pcap clássico.
out.write_all(&0xa1b2c3d4u32.to_le_bytes())?; // magic
out.write_all(&2u16.to_le_bytes())?; // version major
out.write_all(&4u16.to_le_bytes())?; // version minor
out.write_all(&0i32.to_le_bytes())?; // thiszone
out.write_all(&0u32.to_le_bytes())?; // sigfigs
out.write_all(&65535u32.to_le_bytes())?; // snaplen
out.write_all(&LINKTYPE_IEEE802_11_RADIOTAP.to_le_bytes())?;
Ok(Self { out })
}
/// Grava um quadro crú (radiotap + 802.11), com timestamp de agora.
pub fn write_frame(&mut self, raw: &[u8]) -> Result<()> {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
self.out.write_all(&(now.as_secs() as u32).to_le_bytes())?;
self.out.write_all(&(now.subsec_micros()).to_le_bytes())?;
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // caplen
self.out.write_all(&(raw.len() as u32).to_le_bytes())?; // len original
self.out.write_all(raw)?;
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
self.out.flush()?;
Ok(())
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Parser mínimo de cabeçalho Radiotap — só os campos que usamos
//! (canal/frequência e força de sinal). Especificação:
//! <https://www.radiotap.org/>
#[derive(Debug, Clone, Copy, Default)]
pub struct RadiotapInfo {
pub freq_mhz: Option<u16>,
pub signal_dbm: Option<i8>,
pub header_len: usize,
}
// Índices de bit no bitmap "present" que nos interessam.
const BIT_FLAGS: u32 = 1 << 1;
const BIT_RATE: u32 = 1 << 2;
const BIT_CHANNEL: u32 = 1 << 3;
const BIT_ANTSIGNAL: u32 = 1 << 5;
/// Retorna as infos extraídas e quantos bytes o cabeçalho ocupa (para o
/// chamador saber onde o frame 802.11 propriamente começa).
pub fn parse(buf: &[u8]) -> Option<RadiotapInfo> {
if buf.len() < 8 || buf[0] != 0 {
return None; // version deve ser 0
}
let header_len = u16::from_le_bytes([buf[2], buf[3]]) as usize;
if header_len > buf.len() || header_len < 8 {
return None;
}
// Pode haver múltiplas palavras de "present" (bit 31 = continua).
let mut present_words = Vec::new();
let mut offset = 4;
loop {
if offset + 4 > buf.len() {
return None;
}
let word = u32::from_le_bytes([buf[offset], buf[offset + 1], buf[offset + 2], buf[offset + 3]]);
present_words.push(word);
offset += 4;
if word & (1 << 31) == 0 {
break;
}
}
let present = present_words[0]; // só o primeiro conjunto de campos padrão nos interessa
let mut info = RadiotapInfo { header_len, ..Default::default() };
let mut cursor = offset;
// TSFT (u64, align 8) viria antes de Flags; para o nosso subconjunto de
// campos simplificamos assumindo captura via mac80211 (ordem padrão:
// TSFT, Flags, Rate, Channel, ..., AntSignal). Avançamos campo a campo
// respeitando alinhamento, sem travar se algum bit não estiver setado.
macro_rules! align {
($a:expr) => {{
let rem = cursor % $a;
if rem != 0 {
cursor += $a - rem;
}
}};
}
if present & (1 << 0) != 0 {
align!(8);
cursor += 8; // TSFT
}
if present & BIT_FLAGS != 0 {
align!(1);
cursor += 1;
}
if present & BIT_RATE != 0 {
align!(1);
cursor += 1;
}
if present & BIT_CHANNEL != 0 {
align!(2);
if cursor + 4 <= buf.len() {
let freq = u16::from_le_bytes([buf[cursor], buf[cursor + 1]]);
info.freq_mhz = Some(freq);
}
cursor += 4;
}
// FHSS (bit 4) — raro, ignorado.
if present & (1 << 4) != 0 {
align!(2);
cursor += 2;
}
if present & BIT_ANTSIGNAL != 0 {
align!(1);
if cursor < buf.len() {
info.signal_dbm = Some(buf[cursor] as i8);
}
}
Some(info)
}
pub fn freq_to_channel(freq_mhz: u16) -> Option<u32> {
match freq_mhz {
2412..=2472 => Some(((freq_mhz - 2412) / 5 + 1) as u32),
2484 => Some(14),
5000..=5900 => Some(((freq_mhz - 5000) / 5) as u32),
_ => None,
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Agregação de quadros capturados em uma tabela de APs e clientes vistos,
//! no espírito do `airodump-ng` — mas como uma struct reaproveitável tanto
//! pela CLI quanto pela interface gráfica.
use crate::dot11::{self, FrameKind, Security};
use crate::radiotap;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, serde::Serialize)]
pub struct AccessPoint {
pub bssid: String,
pub ssid: Option<String>,
pub channel: Option<u32>,
pub security: Option<String>,
pub last_signal_dbm: Option<i8>,
pub beacons_seen: u64,
pub clients: Vec<String>,
pub last_seen_unix: u64,
}
#[derive(Default)]
pub struct ScanTable {
aps: HashMap<String, AccessPoint>,
}
impl ScanTable {
pub fn new() -> Self {
Self::default()
}
/// Processa um quadro radiotap+802.11 crú, atualizando a tabela.
/// Retorna `true` se o quadro continha um pacote EAPOL (indício de
/// handshake WPA em andamento — útil para o chamador decidir salvar em
/// pcap).
pub fn ingest(&mut self, raw: &[u8]) -> bool {
let rt = match radiotap::parse(raw) {
Some(rt) => rt,
None => return false,
};
if rt.header_len >= raw.len() {
return false;
}
let dot11_buf = &raw[rt.header_len..];
let frame = match dot11::parse(dot11_buf) {
Some(f) => f,
None => return false,
};
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
match frame.kind {
FrameKind::Beacon | FrameKind::ProbeResponse => {
if let Some(bssid) = frame.bssid_str() {
let info = frame.management_info();
let channel =
info.channel_ie.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
let entry = self.aps.entry(bssid.clone()).or_insert_with(|| AccessPoint {
bssid,
ssid: None,
channel: None,
security: None,
last_signal_dbm: None,
beacons_seen: 0,
clients: Vec::new(),
last_seen_unix: now,
});
if let Some(ssid) = info.ssid {
entry.ssid = Some(ssid);
}
if channel.is_some() {
entry.channel = channel;
}
entry.security = info.security.map(security_label);
entry.last_signal_dbm = rt.signal_dbm;
entry.beacons_seen += 1;
entry.last_seen_unix = now;
}
}
FrameKind::Data => {
// Associa o cliente (endereço de origem) ao BSSID observado,
// quando a direção do quadro indicar que vem de uma estação.
if let (Some(bssid), Some(src)) = (frame.bssid_str(), frame.src_str()) {
if let Some(ap) = self.aps.get_mut(&bssid) {
if src != bssid && !ap.clients.contains(&src) {
ap.clients.push(src);
}
}
}
return dot11::is_eapol(frame.body);
}
_ => {}
}
false
}
pub fn access_points(&self) -> Vec<AccessPoint> {
let mut v: Vec<_> = self.aps.values().cloned().collect();
v.sort_by(|a, b| b.beacons_seen.cmp(&a.beacons_seen));
v
}
}
fn security_label(s: Security) -> String {
match s {
Security::Open => "Aberta".into(),
Security::Wep => "WEP".into(),
Security::Wpa => "WPA".into(),
Security::Wpa2 => "WPA2".into(),
Security::Wpa3 => "WPA3".into(),
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Auto-atualização simples: verifica a última release no GitHub, baixa o
//! binário certo para a plataforma atual, confere o checksum e troca o
//! executável em uso — sem o usuário precisar baixar nada manualmente.
//!
//! Usa `curl` (quase universal em Linux/macOS) em vez de trazer uma stack
//! HTTP/TLS inteira como dependência só para isso.
use crate::error::{Result, SabiaError};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
const REPO: &str = "lucasrguerra/sabia";
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UpdateStatus {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
}
fn curl_text(url: &str) -> Result<String> {
let out = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: sabia-updater", url])
.output()
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
if !out.status.success() {
return Err(SabiaError::CommandFailed {
cmd: format!("curl {url}"),
detail: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn curl_download(url: &str, dest: &PathBuf) -> Result<()> {
let status = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: sabia-updater", "-o"])
.arg(dest)
.arg(url)
.status()
.map_err(|e| SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: e.to_string() })?;
if !status.success() {
return Err(SabiaError::CommandFailed { cmd: format!("curl {url}"), detail: "download falhou".into() });
}
Ok(())
}
fn asset_name_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"windows"
};
let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else { "aarch64" };
format!("sabia-{os}-{arch}.tar.gz")
}
/// Consulta a última release publicada e compara com a versão atual
/// (`CARGO_PKG_VERSION` do binário chamador).
pub fn check(current_version: &str) -> Result<UpdateStatus> {
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let body = match curl_text(&url) {
Ok(b) => b,
Err(_) => {
// Sem rede, ou repositório ainda sem releases publicadas: não é
// um erro fatal, só significa "sem atualização disponível".
return Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: None,
update_available: false,
});
}
};
let release: Release = serde_json::from_str(&body)?;
let latest = release.tag_name.trim_start_matches('v').to_string();
let update_available = latest != current_version;
Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: Some(latest),
update_available,
})
}
/// Baixa e aplica a atualização mais recente, substituindo o binário atual.
/// Retorna a versão instalada.
pub fn apply(current_exe: &PathBuf) -> Result<String> {
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let body = curl_text(&url)?;
let release: Release = serde_json::from_str(&body)?;
let wanted = asset_name_for_platform();
let asset = release
.assets
.iter()
.find(|a| a.name == wanted)
.ok_or_else(|| SabiaError::CommandFailed {
cmd: "update".into(),
detail: format!("nenhum asset `{wanted}` na release {}", release.tag_name),
})?;
let checksum_asset = release.assets.iter().find(|a| a.name == format!("{}.sha256", asset.name));
let tmp_dir = std::env::temp_dir().join("sabia-update");
fs::create_dir_all(&tmp_dir)?;
let archive_path = tmp_dir.join(&asset.name);
curl_download(&asset.browser_download_url, &archive_path)?;
if let Some(checksum_asset) = checksum_asset {
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected.split_whitespace().next().unwrap_or("").to_lowercase();
let data = fs::read(&archive_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash = format!("{:x}", hasher.finalize());
if actual_hash != expected_hash {
return Err(SabiaError::CommandFailed {
cmd: "update".into(),
detail: "checksum SHA-256 não confere — atualização descartada".into(),
});
}
}
// Extrai o tar.gz e substitui o binário atual atomicamente.
let extract_dir = tmp_dir.join("extracted");
let _ = fs::remove_dir_all(&extract_dir);
fs::create_dir_all(&extract_dir)?;
let status = Command::new("tar").args(["xzf"]).arg(&archive_path).arg("-C").arg(&extract_dir).status()?;
if !status.success() {
return Err(SabiaError::CommandFailed { cmd: "tar xzf".into(), detail: "extração falhou".into() });
}
let new_binary = find_binary(&extract_dir)?;
let backup = current_exe.with_extension("bak");
let _ = fs::remove_file(&backup);
fs::rename(current_exe, &backup)?;
fs::copy(&new_binary, current_exe)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(current_exe)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(current_exe, perms)?;
}
Ok(release.tag_name)
}
fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name();
if name == "sabia" || name == "sabia.exe" {
return Ok(entry.path());
}
}
Err(SabiaError::CommandFailed { cmd: "update".into(), detail: "binário não encontrado no pacote baixado".into() })
}