From e98941301863acc874a737478ed79d881f3ab354 Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 23 Oct 2026 08:22:04 -0300 Subject: [PATCH] Esqueleto inicial: sabia-core + sabia-cli + UI local, OpenWolf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/commands/designqc.md | 26 + .claude/commands/handoff.md | 25 + .claude/commands/reframe.md | 41 + .claude/commands/security-audit.md | 40 + .claude/rules/openwolf.md | 10 + .claude/settings.json | 144 +++ .claude/skills/openwolf/SKILL.md | 47 + .codex/config.toml | 2 + .codex/hooks.json | 139 +++ .codex/prompts/designqc.md | 26 + .codex/prompts/handoff.md | 25 + .codex/prompts/reframe.md | 41 + .codex/prompts/security-audit.md | 40 + .cursor/rules/openwolf.mdc | 8 + .gitignore | 7 + .opencode/command/designqc.md | 26 + .opencode/command/handoff.md | 25 + .opencode/command/reframe.md | 41 + .opencode/command/security-audit.md | 40 + .opencode/plugin/openwolf.ts | 19 + .opencode/plugin/openwolf/anatomy-lock.ts | 196 ++++ .opencode/plugin/openwolf/anatomy.ts | 424 ++++++++ .opencode/plugin/openwolf/bug-id.ts | 13 + .opencode/plugin/openwolf/bug-journal.ts | 43 + .opencode/plugin/openwolf/event-journal.ts | 76 ++ .opencode/plugin/openwolf/fs.ts | 165 +++ .opencode/plugin/openwolf/handoff-state.ts | 98 ++ .opencode/plugin/openwolf/index.ts | 155 +++ .opencode/plugin/openwolf/knowledge-root.ts | 16 + .opencode/plugin/openwolf/ledger-math.ts | 369 +++++++ .opencode/plugin/openwolf/ledger.ts | 133 +++ .opencode/plugin/openwolf/post-read.ts | 41 + .opencode/plugin/openwolf/post-write.ts | 261 +++++ .opencode/plugin/openwolf/pre-read.ts | 100 ++ .opencode/plugin/openwolf/pre-write.ts | 110 ++ .opencode/plugin/openwolf/runtime-updates.ts | 119 +++ .opencode/plugin/openwolf/session-state.ts | 20 + .opencode/plugin/openwolf/session.ts | 93 ++ .opencode/plugin/openwolf/shared.ts | 1005 ++++++++++++++++++ .opencode/plugin/openwolf/stop.ts | 250 +++++ .opencode/plugin/openwolf/trusted-memory.ts | 56 + .opencode/plugin/openwolf/types.ts | 47 + .opencode/plugin/openwolf/usage.ts | 42 + .opencode/plugin/openwolf/visibility.ts | 96 ++ .wolf/.gitignore | 26 + .wolf/OPENWOLF.md | 44 + .wolf/STATUS.md | 68 ++ .wolf/anatomy-index.json | 907 ++++++++++++++++ .wolf/anatomy.md | 48 + .wolf/buglog.json | 27 + .wolf/cerebrum.md | 26 + .wolf/config.json | 126 +++ .wolf/cron-manifest.json | 66 ++ .wolf/memory.md | 34 + AGENTS.md | 5 + CLAUDE.md | 5 + Cargo.lock | 495 +++++++++ Cargo.toml | 17 + GEMINI.md | 5 + README.md | 47 + crates/sabia-cli/Cargo.toml | 19 + crates/sabia-cli/src/main.rs | 216 ++++ crates/sabia-cli/src/web/index.html | 157 +++ crates/sabia-cli/src/web/mod.rs | 222 ++++ crates/sabia-core/Cargo.toml | 21 + crates/sabia-core/src/audit.rs | 114 ++ crates/sabia-core/src/capture.rs | 122 +++ crates/sabia-core/src/dot11.rs | 183 ++++ crates/sabia-core/src/error.rs | 27 + crates/sabia-core/src/iface.rs | 110 ++ crates/sabia-core/src/lib.rs | 19 + crates/sabia-core/src/netlink.rs | 95 ++ crates/sabia-core/src/pcapwriter.rs | 47 + crates/sabia-core/src/radiotap.rs | 103 ++ crates/sabia-core/src/scan.rs | 112 ++ crates/sabia-core/src/update.rs | 173 +++ 76 files changed, 8386 insertions(+) create mode 100644 .claude/commands/designqc.md create mode 100644 .claude/commands/handoff.md create mode 100644 .claude/commands/reframe.md create mode 100644 .claude/commands/security-audit.md create mode 100644 .claude/rules/openwolf.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/openwolf/SKILL.md create mode 100644 .codex/config.toml create mode 100644 .codex/hooks.json create mode 100644 .codex/prompts/designqc.md create mode 100644 .codex/prompts/handoff.md create mode 100644 .codex/prompts/reframe.md create mode 100644 .codex/prompts/security-audit.md create mode 100644 .cursor/rules/openwolf.mdc create mode 100644 .gitignore create mode 100644 .opencode/command/designqc.md create mode 100644 .opencode/command/handoff.md create mode 100644 .opencode/command/reframe.md create mode 100644 .opencode/command/security-audit.md create mode 100644 .opencode/plugin/openwolf.ts create mode 100644 .opencode/plugin/openwolf/anatomy-lock.ts create mode 100644 .opencode/plugin/openwolf/anatomy.ts create mode 100644 .opencode/plugin/openwolf/bug-id.ts create mode 100644 .opencode/plugin/openwolf/bug-journal.ts create mode 100644 .opencode/plugin/openwolf/event-journal.ts create mode 100644 .opencode/plugin/openwolf/fs.ts create mode 100644 .opencode/plugin/openwolf/handoff-state.ts create mode 100644 .opencode/plugin/openwolf/index.ts create mode 100644 .opencode/plugin/openwolf/knowledge-root.ts create mode 100644 .opencode/plugin/openwolf/ledger-math.ts create mode 100644 .opencode/plugin/openwolf/ledger.ts create mode 100644 .opencode/plugin/openwolf/post-read.ts create mode 100644 .opencode/plugin/openwolf/post-write.ts create mode 100644 .opencode/plugin/openwolf/pre-read.ts create mode 100644 .opencode/plugin/openwolf/pre-write.ts create mode 100644 .opencode/plugin/openwolf/runtime-updates.ts create mode 100644 .opencode/plugin/openwolf/session-state.ts create mode 100644 .opencode/plugin/openwolf/session.ts create mode 100644 .opencode/plugin/openwolf/shared.ts create mode 100644 .opencode/plugin/openwolf/stop.ts create mode 100644 .opencode/plugin/openwolf/trusted-memory.ts create mode 100644 .opencode/plugin/openwolf/types.ts create mode 100644 .opencode/plugin/openwolf/usage.ts create mode 100644 .opencode/plugin/openwolf/visibility.ts create mode 100644 .wolf/.gitignore create mode 100644 .wolf/OPENWOLF.md create mode 100644 .wolf/STATUS.md create mode 100644 .wolf/anatomy-index.json create mode 100644 .wolf/anatomy.md create mode 100644 .wolf/buglog.json create mode 100644 .wolf/cerebrum.md create mode 100644 .wolf/config.json create mode 100644 .wolf/cron-manifest.json create mode 100644 .wolf/memory.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 GEMINI.md create mode 100644 README.md create mode 100644 crates/sabia-cli/Cargo.toml create mode 100644 crates/sabia-cli/src/main.rs create mode 100644 crates/sabia-cli/src/web/index.html create mode 100644 crates/sabia-cli/src/web/mod.rs create mode 100644 crates/sabia-core/Cargo.toml create mode 100644 crates/sabia-core/src/audit.rs create mode 100644 crates/sabia-core/src/capture.rs create mode 100644 crates/sabia-core/src/dot11.rs create mode 100644 crates/sabia-core/src/error.rs create mode 100644 crates/sabia-core/src/iface.rs create mode 100644 crates/sabia-core/src/lib.rs create mode 100644 crates/sabia-core/src/netlink.rs create mode 100644 crates/sabia-core/src/pcapwriter.rs create mode 100644 crates/sabia-core/src/radiotap.rs create mode 100644 crates/sabia-core/src/scan.rs create mode 100644 crates/sabia-core/src/update.rs diff --git a/.claude/commands/designqc.md b/.claude/commands/designqc.md new file mode 100644 index 0000000..74b6b63 --- /dev/null +++ b/.claude/commands/designqc.md @@ -0,0 +1,26 @@ +--- +description: Screenshot-based design review of the running app via openwolf designqc +argument-hint: [--url ] [--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 ` 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. diff --git a/.claude/commands/handoff.md b/.claude/commands/handoff.md new file mode 100644 index 0000000..dafa621 --- /dev/null +++ b/.claude/commands/handoff.md @@ -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 --session --file `. Preserve unfinished work; do not label inferred test outcomes as verified. +3. Run `openwolf handoff export --from --session --to --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 ` and imports it into its own exact session using `openwolf handoff import --to --session `. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets. +5. Use `openwolf handoff recover --from --session ` if hooks were missed, and `openwolf handoff search ""` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules. diff --git a/.claude/commands/reframe.md b/.claude/commands/reframe.md new file mode 100644 index 0000000..49d4b14 --- /dev/null +++ b/.claude/commands/reframe.md @@ -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.** diff --git a/.claude/commands/security-audit.md b/.claude/commands/security-audit.md new file mode 100644 index 0000000..2ba9685 --- /dev/null +++ b/.claude/commands/security-audit.md @@ -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. diff --git a/.claude/rules/openwolf.md b/.claude/rules/openwolf.md new file mode 100644 index 0000000..7ee74e6 --- /dev/null +++ b/.claude/rules/openwolf.md @@ -0,0 +1,10 @@ +--- +description: OpenWolf protocol enforcement, active on all files +globs: **/* +--- + +- To locate a symbol or file, run `openwolf find ` first (ranked shortlist, under 1k tokens). For one file's description and symbol ranges: `openwolf find --file `. 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 ""` 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. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..03c8de6 --- /dev/null +++ b/.claude/settings.json @@ -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\"" + } +} \ No newline at end of file diff --git a/.claude/skills/openwolf/SKILL.md b/.claude/skills/openwolf/SKILL.md new file mode 100644 index 0000000..eaeacf2 --- /dev/null +++ b/.claude/skills/openwolf/SKILL.md @@ -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 ` 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 `. +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. diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..146af7e --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[features] +hooks = true diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..e185c5c --- /dev/null +++ b/.codex/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/.codex/prompts/designqc.md b/.codex/prompts/designqc.md new file mode 100644 index 0000000..74b6b63 --- /dev/null +++ b/.codex/prompts/designqc.md @@ -0,0 +1,26 @@ +--- +description: Screenshot-based design review of the running app via openwolf designqc +argument-hint: [--url ] [--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 ` 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. diff --git a/.codex/prompts/handoff.md b/.codex/prompts/handoff.md new file mode 100644 index 0000000..dafa621 --- /dev/null +++ b/.codex/prompts/handoff.md @@ -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 --session --file `. Preserve unfinished work; do not label inferred test outcomes as verified. +3. Run `openwolf handoff export --from --session --to --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 ` and imports it into its own exact session using `openwolf handoff import --to --session `. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets. +5. Use `openwolf handoff recover --from --session ` if hooks were missed, and `openwolf handoff search ""` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules. diff --git a/.codex/prompts/reframe.md b/.codex/prompts/reframe.md new file mode 100644 index 0000000..49d4b14 --- /dev/null +++ b/.codex/prompts/reframe.md @@ -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.** diff --git a/.codex/prompts/security-audit.md b/.codex/prompts/security-audit.md new file mode 100644 index 0000000..2ba9685 --- /dev/null +++ b/.codex/prompts/security-audit.md @@ -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. diff --git a/.cursor/rules/openwolf.mdc b/.cursor/rules/openwolf.mdc new file mode 100644 index 0000000..a999cc8 --- /dev/null +++ b/.cursor/rules/openwolf.mdc @@ -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). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..978daa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +**/*.rs.bk +*.pcap +*.cap +.sabia-audit.log +node_modules/ +apps/gui/src-tauri/target diff --git a/.opencode/command/designqc.md b/.opencode/command/designqc.md new file mode 100644 index 0000000..74b6b63 --- /dev/null +++ b/.opencode/command/designqc.md @@ -0,0 +1,26 @@ +--- +description: Screenshot-based design review of the running app via openwolf designqc +argument-hint: [--url ] [--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 ` 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. diff --git a/.opencode/command/handoff.md b/.opencode/command/handoff.md new file mode 100644 index 0000000..dafa621 --- /dev/null +++ b/.opencode/command/handoff.md @@ -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 --session --file `. Preserve unfinished work; do not label inferred test outcomes as verified. +3. Run `openwolf handoff export --from --session --to --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 ` and imports it into its own exact session using `openwolf handoff import --to --session `. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets. +5. Use `openwolf handoff recover --from --session ` if hooks were missed, and `openwolf handoff search ""` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules. diff --git a/.opencode/command/reframe.md b/.opencode/command/reframe.md new file mode 100644 index 0000000..49d4b14 --- /dev/null +++ b/.opencode/command/reframe.md @@ -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.** diff --git a/.opencode/command/security-audit.md b/.opencode/command/security-audit.md new file mode 100644 index 0000000..2ba9685 --- /dev/null +++ b/.opencode/command/security-audit.md @@ -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. diff --git a/.opencode/plugin/openwolf.ts b/.opencode/plugin/openwolf.ts new file mode 100644 index 0000000..acdc0ce --- /dev/null +++ b/.opencode/plugin/openwolf.ts @@ -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) +} diff --git a/.opencode/plugin/openwolf/anatomy-lock.ts b/.opencode/plugin/openwolf/anatomy-lock.ts new file mode 100644 index 0000000..23d95e0 --- /dev/null +++ b/.opencode/plugin/openwolf/anatomy-lock.ts @@ -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(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( + 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(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). */ diff --git a/.opencode/plugin/openwolf/anatomy.ts b/.opencode/plugin/openwolf/anatomy.ts new file mode 100644 index 0000000..7350535 --- /dev/null +++ b/.opencode/plugin/openwolf/anatomy.ts @@ -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 + rawLines: Map + preamble: string[] +} { + const sections = new Map() + const rawLines = new Map() + 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 { + 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 = { + "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 + /** Non-conforming anatomy.md lines preserved verbatim, keyed by section. */ + rawLines?: Record + /** + * 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>() + 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() + 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(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 {} + } +} diff --git a/.opencode/plugin/openwolf/bug-id.ts b/.opencode/plugin/openwolf/bug-id.ts new file mode 100644 index 0000000..e113360 --- /dev/null +++ b/.opencode/plugin/openwolf/bug-id.ts @@ -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")}`; +} diff --git a/.opencode/plugin/openwolf/bug-journal.ts b/.opencode/plugin/openwolf/bug-journal.ts new file mode 100644 index 0000000..c0863b6 --- /dev/null +++ b/.opencode/plugin/openwolf/bug-journal.ts @@ -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; +} diff --git a/.opencode/plugin/openwolf/event-journal.ts b/.opencode/plugin/openwolf/event-journal.ts new file mode 100644 index 0000000..b33a2c2 --- /dev/null +++ b/.opencode/plugin/openwolf/event-journal.ts @@ -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>(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; diff --git a/.opencode/plugin/openwolf/fs.ts b/.opencode/plugin/openwolf/fs.ts new file mode 100644 index 0000000..c65aca8 --- /dev/null +++ b/.opencode/plugin/openwolf/fs.ts @@ -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 { + 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(defaults: T, loaded: T): T { + if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded + const result: Record = { ...(defaults as Record) } + for (const key of Object.keys(loaded as Record)) { + const lv = (loaded as Record)[key] + const dv = (defaults as Record)[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(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 +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/handoff-state.ts b/.opencode/plugin/openwolf/handoff-state.ts new file mode 100644 index 0000000..f6aa6c0 --- /dev/null +++ b/.opencode/plugin/openwolf/handoff-state.ts @@ -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;compactions:number;injections:Array<{hash:string;at:string;bytes:number}>; + imported_packet?:string;coverage:string[];last_context?:Record; +} +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>} +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(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={}; + 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(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:''; +} diff --git a/.opencode/plugin/openwolf/index.ts b/.opencode/plugin/openwolf/index.ts new file mode 100644 index 0000000..71f66cd --- /dev/null +++ b/.opencode/plugin/openwolf/index.ts @@ -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 = 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>(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>(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 }) => { + if (!wolfDirExists(directory)) return + + const sessionId = extractSessionId(input) + if (!sessionId) return + + const args: Record = 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; callID?: string }, output: Record) => { + 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) => { + if (!wolfDirExists(directory)) return + + const sessionId = extractSessionId(input) + if (!sessionId) return + + handleStop(directory, sessionId) + }, + + "experimental.chat.system.transform": async (_input: Record, 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\n${openwolfContent}\n`) + } catch {} + } + }, + } +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/knowledge-root.ts b/.opencode/plugin/openwolf/knowledge-root.ts new file mode 100644 index 0000000..68e410e --- /dev/null +++ b/.opencode/plugin/openwolf/knowledge-root.ts @@ -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;} +} diff --git a/.opencode/plugin/openwolf/ledger-math.ts b/.opencode/plugin/openwolf/ledger-math.ts new file mode 100644 index 0000000..cdfac39 --- /dev/null +++ b/.opencode/plugin/openwolf/ledger-math.ts @@ -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; + files_written: Array<{ file: string; action: string; tokens: number; at: string }>; + edit_counts: Record; + anatomy_hits: number; + anatomy_misses: number; + repeated_reads_warned: number; + reads_denied?: number; + denied_tokens_saved?: number; + stop_count: number; + reminders_sent: Record; + pending_reminders?: string[]; + injected_tokens_estimated?: number; + injected_by_source?: Record; + 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( + target: Record, + source: Record | 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)[field] = ((bucket as Record)[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; + /** Tokens the agent spent reading .wolf/ state itself (2.4). */ + wolf_internal_tokens?: number; + }; + injected_by_source?: Record; + 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; + 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; + /** Measured usage per model id, lifetime. Drives the cost figure. */ + real_by_model: Record; +} + +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; + /** 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 | undefined { + if (!governed || governed.length === 0) return undefined; + const out: Record = {}; + 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(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, 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 | undefined): Record { + const out: Record = {}; + 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 | undefined); +} + +export function foldEntry(acc: Record, 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; +} diff --git a/.opencode/plugin/openwolf/ledger.ts b/.opencode/plugin/openwolf/ledger.ts new file mode 100644 index 0000000..2e7583f --- /dev/null +++ b/.opencode/plugin/openwolf/ledger.ts @@ -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 = (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(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(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(dest,null); + if (!previous || e.ended >= previous.ended) writeJSON(dest,e); + } + const all = fs.readdirSync(snapshots).filter(n=>n.endsWith(".json")).map(n=>read(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; +} diff --git a/.opencode/plugin/openwolf/post-read.ts b/.opencode/plugin/openwolf/post-read.ts new file mode 100644 index 0000000..e772dc7 --- /dev/null +++ b/.opencode/plugin/openwolf/post-read.ts @@ -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) +} diff --git a/.opencode/plugin/openwolf/post-write.ts b/.opencode/plugin/openwolf/post-write.ts new file mode 100644 index 0000000..b5a1215 --- /dev/null +++ b/.opencode/plugin/openwolf/post-write.ts @@ -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("; ") +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/pre-read.ts b/.opencode/plugin/openwolf/pre-read.ts new file mode 100644 index 0000000..72b44b5 --- /dev/null +++ b/.opencode/plugin/openwolf/pre-read.ts @@ -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(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) +} diff --git a/.opencode/plugin/openwolf/pre-write.ts b/.opencode/plugin/openwolf/pre-write.ts new file mode 100644 index 0000000..d05f7d8 --- /dev/null +++ b/.opencode/plugin/openwolf/pre-write.ts @@ -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 { + 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)}`) + } +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/runtime-updates.ts b/.opencode/plugin/openwolf/runtime-updates.ts new file mode 100644 index 0000000..288dc44 --- /dev/null +++ b/.opencode/plugin/openwolf/runtime-updates.ts @@ -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{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 { + 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.`; +} diff --git a/.opencode/plugin/openwolf/session-state.ts b/.opencode/plugin/openwolf/session-state.ts new file mode 100644 index 0000000..b00eb44 --- /dev/null +++ b/.opencode/plugin/openwolf/session-state.ts @@ -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 { + const s: Record = 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; +} diff --git a/.opencode/plugin/openwolf/session.ts b/.opencode/plugin/openwolf/session.ts new file mode 100644 index 0000000..927fb9a --- /dev/null +++ b/.opencode/plugin/openwolf/session.ts @@ -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() + +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(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>(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) +} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/shared.ts b/.opencode/plugin/openwolf/shared.ts new file mode 100644 index 0000000..7ce420f --- /dev/null +++ b/.opencode/plugin/openwolf/shared.ts @@ -0,0 +1,1005 @@ +import {hookReceipt} from './visibility.js'; +import {observeCheckpoint} from "./handoff-state.js"; +import {bridgedInput, delegateRuntime, scheduleUpdate} from "./runtime-updates.js"; +import { normalizeSession } from "./session-state.js"; +export { normalizeSession } from "./session-state.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"; +import { fileURLToPath } from "node:url"; + +// Prefer the harness-provided project dir so hooks work even if CWD changes +// during a session. Each supported agent exposes its own env var; hooks are +// provider-agnostic (Workstream C) so all are checked. +// +// None of those vars are guaranteed. Claude Code in particular does not put +// CLAUDE_PROJECT_DIR in the hook process's environment on any platform: it +// delivers project context through the stdin JSON payload instead. So before +// falling back to CWD, derive the root from this script's own location. A hook +// always runs as /.wolf/hooks/.js, which makes the project root +// two directories up, verified by the .wolf/ directory being there. +function projectDirFromScriptLocation(): string | null { + try { + const here = path.dirname(fileURLToPath(import.meta.url)); + if (path.basename(here) !== "hooks") return null; + const root = path.resolve(here, "..", ".."); + if (path.basename(path.dirname(here)) !== ".wolf") return null; + return fs.existsSync(path.join(root, ".wolf")) ? root : null; + } catch { + return null; + } +} + +export function getProjectDir(): string { + if (hookCwd) { + let dir = path.resolve(hookCwd); + while (true) { + if (fs.existsSync(path.join(dir, ".wolf"))) return dir; + if (fs.existsSync(path.join(dir, ".git")) || path.dirname(dir) === dir) break; + dir = path.dirname(dir); + } + return hookCwd; + } + return ( + process.env.CLAUDE_PROJECT_DIR || + process.env.CODEX_PROJECT_ROOT || + process.env.GROK_WORKSPACE_ROOT || + process.env.OPENWOLF_PROJECT_ROOT || + projectDirFromScriptLocation() || + process.cwd() + ); +} + +export function getWolfDir(): string { + return path.join(getProjectDir(), ".wolf"); +} + +/** Which agent harness invoked this hook — used for per-agent ledger attribution. */ +export function detectAgent(): string { + if(process.env.GROK_HOOK_EVENT||process.env.GROK_SESSION_ID)return "grok"; + // CLAUDECODE is set in every Claude Code hook process; CLAUDE_PROJECT_DIR is + // not set at all, so checking it alone attributed real Claude sessions to + // "default" and lost their per-agent ledger rows. + if (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.CLAUDE_PROJECT_DIR) return "claude"; + if (process.env.CODEX_PROJECT_ROOT || process.env.CODEX_SANDBOX || process.env.CODEX_THREAD_ID) return "codex"; + if (process.env.OPENCODE || process.env.OPENCODE_PROJECT_ROOT) return "opencode"; + return "default"; +} + +/** + * Bail out silently if .wolf/ directory doesn't exist in the current project. + * Call this at the top of every hook to avoid crashes in non-OpenWolf projects. + */ +export function ensureWolfDir(): void { + const wolfDir = getWolfDir(); + if (!fs.existsSync(wolfDir)) { + process.exit(0); + } +} + +// ─── Hook health (2.2): heartbeat + crash recording ────────────────────────── +// The PostToolUse write hook crashed on 100% of 440 invocations for 3 weeks +// with nothing noticing, because every hook swallowed its own errors +// (main().catch(() => exit(0))). Every hook now runs through hookMain(), which +// records a per-hook heartbeat (last success / last error / consecutive +// failures) that session-start, update, and the dashboard can check. + +export const HEARTBEAT_FILE = "_heartbeat.json"; + +interface HeartbeatEntry { + last_ok?: string; + last_error?: string; + last_error_message?: string; + consecutive_failures: number; +} + +export function recordHeartbeat(hookName: string, error?: unknown): void { + try { + const file = path.join(getWolfDir(), "hooks", HEARTBEAT_FILE); + const beats = readJSON>(file, {}); + const entry = beats[hookName] ?? { consecutive_failures: 0 }; + if (error === undefined) { + entry.last_ok = new Date().toISOString(); + entry.consecutive_failures = 0; + } else { + entry.last_error = new Date().toISOString(); + entry.last_error_message = String(error instanceof Error ? error.stack ?? error.message : error).slice(0, 500); + entry.consecutive_failures = (entry.consecutive_failures ?? 0) + 1; + } + beats[hookName] = entry; + writeJSON(file, beats); + } catch {} +} + +/** + * Standard hook entry point: runs the hook, records a heartbeat either way, + * always exits 0 (hooks must never block the agent). `--selfcheck` exits + * immediately after module load: reaching this code at all proves every static + * import resolved, which is exactly the failure class that went undetected. + */ +export function hookMain(hookName: string, fn: () => void | Promise): void { + if (process.argv.includes("--selfcheck")) { + process.stdout.write(`ok ${hookName}`); + process.exit(0); + } + Promise.resolve() + .then(() => readStdin()) + .then(async raw => { + if (hookName === "session-start" || hookName === "user-prompt-submit") scheduleUpdate(getProjectDir()); + try { if (await delegateRuntime(getProjectDir(), hookName, raw)) return true; } catch {} + await fn(); + try { if (["precompact","session-end","stop","user-prompt-submit","post-write","post-bash"].includes(hookName)) observeCheckpoint(getProjectDir(),detectAgent(),hookName,JSON.parse(raw)); } catch {} + if(hookName==="stop")try{const notice=hookReceipt(getProjectDir(),detectAgent(),{...JSON.parse(raw),prompt_id:String(readJSON>(getSessionFilePath(JSON.parse(raw)),{}).stop_count??0)});if(notice)process.stdout.write(JSON.stringify({systemMessage:notice}))}catch{} + return false; + }) + .then(delegated => { + if (delegated) return; + recordHeartbeat(hookName); + process.exit(0); + }) + .catch((err) => { + recordHeartbeat(hookName, err); + process.exit(0); + }); +} + +// ─── Session-keyed state (2.2) ─────────────────────────────────────────────── +// _session.json used to be one per-PROJECT file, so concurrent Claude sessions +// cross-contaminated each other's read tracking (one of the two causes of the +// ledger's ~20x duplicate-warning inflation). State is now keyed by the +// harness-provided session_id when present. + +/** Resolve the session state file for this hook invocation. */ +export function getSessionFilePath(hookInput: { session_id?: string } | undefined): string { + const hooksDir = path.join(getWolfDir(), "hooks"); + const id = hookInput?.session_id; + if (typeof id === "string" && /^[\w.-]{4,128}$/.test(id)) { + return path.join(hooksDir, "sessions", `${id}.json`); + } + // Legacy fallback for agents that pass no session id. + return path.join(hooksDir, "_session.json"); +} + +/** Delete session state files older than maxAgeDays (called from session-start). */ +export function gcSessionFiles(maxAgeDays = 7, wolfDir = getWolfDir()): void { + try { + const dir = path.join(wolfDir, "hooks", "sessions"); + const cutoff = Date.now() - maxAgeDays * 24 * 3600 * 1000; + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith(".json")) continue; + try { + const file = path.join(dir, f); + const state = readJSON>(file, {}); + if (state.ended && fs.statSync(file).mtimeMs < cutoff) { + const pending = file + ".events"; + if (fs.existsSync(pending) && fs.readdirSync(pending).length) continue; + if (fs.existsSync(pending)) fs.rmdirSync(pending); + fs.unlinkSync(file); + } + } catch {} + } + } catch {} +} + +export function readJSON(filePath: string, fallback: T): T { + try { + const value = JSON.parse(fs.readFileSync(filePath, "utf-8")); + return (/[/\\]hooks[/\\]sessions[/\\][^/\\]+\.json$/.test(filePath) + ? normalizeSession(value, path.basename(filePath, ".json")) : value) as T; + } catch { + return fallback; + } +} + +/** + * Reads .wolf/buglog.json in any shape a project might have on disk and always + * returns { version, bugs }. A hand-written log is often a bare array of + * entries; that shape used to reach `bugLog.bugs.length` and throw, which is + * how pre-write racked up 465 consecutive failures on one project before + * anyone noticed (the hook heartbeat was the only witness). + */ +export function readBugLogFile(wolfDir: string): { version: number; bugs: any[] } { + const raw = readJSON(path.join(wolfDir, "buglog.json"), null); + if (Array.isArray(raw)) return { version: 1, bugs: raw }; + if (raw && typeof raw === "object") { + const bugs = (raw as { bugs?: unknown }).bugs; + if (Array.isArray(bugs)) return { ...(raw as object), version: 1, bugs } as { version: number; bugs: any[] }; + } + return { version: 1, bugs: [] }; +} + +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 { + // On Windows, rename can fail if another process holds a handle. + // Fall back to direct write and clean up the tmp file. + try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); } catch {} + try { fs.unlinkSync(tmp); } catch {} + } +} + +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) { + // Preserve an observation for maintenance to drain; never drop it. + 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}); + } +} + +// parseAnatomy / serializeAnatomy / AnatomyEntry moved to ./anatomy-store.ts — +// the single canonical home of the anatomy format (OPENWOLF-2.0 §F2b). + +// Files whose contents (or content-derived descriptions) must never reach +// anatomy.md / memory.md because they hold secrets (issue #54). Kept in sync +// with the copy in src/scanner/anatomy-scanner.ts — hooks are standalone +// scripts and the scanner cannot be imported from here. +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; +} + +export function extractDescription(filePath: string): string { + const MAX_DESC = 150; + const basename = path.basename(filePath); + const ext = path.extname(basename).toLowerCase(); + const known: Record = { + "package.json": "Node.js package manifest", + "tsconfig.json": "TypeScript configuration", + ".gitignore": "Git ignore rules", + "README.md": "Project documentation", + "composer.json": "PHP package manifest", + "requirements.txt": "Python dependencies", + "schema.sql": "Database schema", + "Dockerfile": "Docker container definition", + "docker-compose.yml": "Docker Compose services", + "Cargo.toml": "Rust package manifest", + "go.mod": "Go module definition", + "Gemfile": "Ruby dependencies", + "pubspec.yaml": "Dart/Flutter package manifest", + }; + if (known[basename]) return known[basename]; + + let content: string; + try { + const fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(12288); // 12KB + 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) + "..."; + + // Markdown heading + if (ext === ".md" || ext === ".mdx") { + const m = content.match(/^#{1,2}\s+(.+)$/m); + if (m) return cap(m[1].trim()); + } + + // HTML title + if (ext === ".html" || ext === ".htm") { + const m = content.match(/]*>([^<]+)<\/title>/i); + if (m) return cap(m[1].trim()); + } + + // JSDoc / PHPDoc / Javadoc — first meaningful line + const jm = content.match(/\/\*\*\s*\n?\s*\*?\s*(.+)/); + if (jm) { + const l = jm[1].replace(/\*\/$/, "").trim(); + if (l && !l.startsWith("@") && l.length > 5) return cap(l); + } + + // Python docstring + if (ext === ".py") { + const dm = content.match(/^(?:#[^\n]*\n)*\s*(?:"""(.+?)"""|'''(.+?)''')/s); + if (dm) { + const first = (dm[1] || dm[2]).split("\n")[0].trim(); + if (first && first.length > 3) return cap(first); + } + } + + // Rust doc comments + if (ext === ".rs") { + const lines = content.split("\n"); + for (const line of lines.slice(0, 20)) { + const m = line.match(/^\s*(?:\/\/\/|\/\/!)\s*(.+)/); + if (m && m[1].length > 5) return cap(m[1].trim()); + } + } + + // Go package comment + if (ext === ".go") { + const m = content.match(/\/\/\s*Package\s+\w+\s+(.*)/); + if (m) return cap(m[1].trim()); + } + + // C# XML doc + if (ext === ".cs") { + const m = content.match(/\s*([\s\S]*?)\s*<\/summary>/); + if (m) { + const text = m[1].replace(/\/\/\/\s*/g, "").replace(/\s+/g, " ").trim(); + if (text.length > 5) return cap(text); + } + } + + // Elixir @moduledoc + if (ext === ".ex" || ext === ".exs") { + const m = content.match(/@moduledoc\s+"""\s*\n\s*(.*)/); + if (m) return cap(m[1].trim()); + } + + // Header comment (skip generic ones) + const hdrLines = content.split("\n"); + for (const line of hdrLines.slice(0, 15)) { + const t = line.trim(); + if (!t || t === " 5 && !lower.startsWith("copyright") && !lower.startsWith("license") && !lower.startsWith("@") && !lower.startsWith("strict") && !lower.startsWith("generated") && !lower.startsWith("eslint-") && !lower.startsWith("nolint")) { + return cap(text); + } + } + if (!t.startsWith("//") && !t.startsWith("#") && !t.startsWith("/*") && !t.startsWith("*") && !t.startsWith("--")) break; + } + + // ─── PHP / Laravel ─────────────────────────────────────── + if (ext === ".php") { + if (basename.endsWith(".blade.php")) { + const ext2 = content.match(/@extends\(\s*['"]([^'"]+)['"]\s*\)/); + const sections = (content.match(/@section\(\s*['"](\w+)['"]/g) || []).map(s => s.match(/['"](\w+)['"]/)?.[1]).filter(Boolean); + const parts: string[] = []; + if (ext2) parts.push(`extends ${ext2[1]}`); + if (sections.length) parts.push(`sections: ${sections.join(", ")}`); + return cap(parts.length ? `Blade: ${parts.join(", ")}` : "Blade template"); + } + + const classM = content.match(/class\s+(\w+)(?:\s+extends\s+(\w+))?/); + const className = classM?.[1] || ""; + const parent = classM?.[2] || ""; + const pubMethods = (content.match(/public\s+function\s+(\w+)/g) || []) + .map(m => m.match(/public\s+function\s+(\w+)/)?.[1]) + .filter(n => n && n !== "__construct" && n !== "middleware") as string[]; + + if (basename.endsWith("Controller.php") || parent === "Controller") { + if (pubMethods.length > 0) { + const display = pubMethods.slice(0, 5).join(", "); + return cap(pubMethods.length > 5 ? `${display} + ${pubMethods.length - 5} more` : display); + } + } + + if (parent === "Model" || parent === "Authenticatable") { + const parts: string[] = []; + const tbl = content.match(/\$table\s*=\s*['"]([^'"]+)['"]/); + if (tbl) parts.push(`table: ${tbl[1]}`); + const fill = content.match(/\$fillable\s*=\s*\[([^\]]*)\]/s); + if (fill) { const c = (fill[1].match(/['"]/g) || []).length / 2; parts.push(`${Math.floor(c)} fields`); } + const rels = (content.match(/\$this->(hasMany|hasOne|belongsTo|belongsToMany|morphMany|morphTo)\(/g) || []).length; + if (rels) parts.push(`${rels} rels`); + return cap(parts.length ? `Model — ${parts.join(", ")}` : `Model: ${className}`); + } + + if (basename.match(/^\d{4}_\d{2}_\d{2}/)) { + const create = content.match(/Schema::create\(\s*['"]([^'"]+)['"]/); + if (create) return `Migration: create ${create[1]} table`; + const alter = content.match(/Schema::table\(\s*['"]([^'"]+)['"]/); + if (alter) return `Migration: alter ${alter[1]} table`; + return "Database migration"; + } + + if (className && pubMethods.length > 0) { + const display = pubMethods.slice(0, 4).join(", "); + return cap(pubMethods.length > 4 ? `${className}: ${display} + ${pubMethods.length - 4} more` : `${className}: ${display}`); + } + } + + // ─── TS/JS/React/Next.js ───────────────────────────────── + if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") { + // React component + if (ext === ".tsx" || ext === ".jsx") { + const comp = content.match(/(?:export\s+(?:default\s+)?)?(?:function|const)\s+(\w+)/); + const parts: string[] = []; + if (comp) parts.push(comp[1]); + const renders: string[] = []; + if (/<(?:form|Form)/i.test(content)) renders.push("form"); + if (/<(?:table|Table|DataTable)/i.test(content)) renders.push("table"); + if (/<(?:dialog|Dialog|Modal|Drawer)/i.test(content)) renders.push("modal"); + if (renders.length) parts.push(`renders ${renders.join(", ")}`); + if (parts.length) return cap(parts.join(" — ")); + } + + // Next.js conventions + if (basename === "page.tsx" || basename === "page.js") return "Next.js page component"; + if (basename === "layout.tsx" || basename === "layout.js") return "Next.js layout"; + if (basename === "route.ts" || basename === "route.js") { + const methods = [...new Set((content.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)/g) || []) + .map(m => m.match(/(GET|POST|PUT|PATCH|DELETE)/)?.[1]))].filter(Boolean); + return methods.length ? `Next.js API route: ${methods.join(", ")}` : "Next.js API route"; + } + + // Express/Fastify routes + const routeHits = content.match(/\.(get|post|put|patch|delete)\s*\(\s*['"`]/g); + if (routeHits && routeHits.length > 0) { + const methods = [...new Set(routeHits.map(r => r.match(/\.(get|post|put|patch|delete)/)?.[1]?.toUpperCase()))]; + return cap(`API routes: ${methods.join(", ")} (${routeHits.length} endpoints)`); + } + + // tRPC router + if (content.includes("createTRPCRouter") || content.includes("publicProcedure")) { + const procs = (content.match(/\.(query|mutation|subscription)\s*\(/g) || []).length; + return procs ? `tRPC router: ${procs} procedures` : "tRPC router"; + } + + // Zod schemas + if (content.includes("z.object") || content.includes("z.string")) { + const schemas = (content.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*z\./g) || []) + .map(s => s.match(/(?:const|let)\s+(\w+)/)?.[1]).filter(Boolean); + if (schemas.length) return cap(`Zod schemas: ${schemas.slice(0, 4).join(", ")}${schemas.length > 4 ? ` + ${schemas.length - 4} more` : ""}`); + } + + // Exports summary + 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`); + } + + // ─── Python / Django / FastAPI / Flask ──────────────────── + if (ext === ".py") { + // Django model + if (content.includes("models.Model")) { + const cls = content.match(/class\s+(\w+)\(.*models\.Model\)/); + const fields = (content.match(/^\s+\w+\s*=\s*models\.\w+/gm) || []).length; + return cap(`Model: ${cls?.[1] || "unknown"}, ${fields} fields`); + } + // FastAPI/Flask routes + if (content.includes("@router.") || content.includes("@app.")) { + const routes = (content.match(/@(?:router|app)\.(get|post|put|patch|delete)\s*\(/g) || []); + return cap(routes.length ? `API: ${routes.length} endpoints` : "API router"); + } + // Pydantic + if (content.includes("BaseModel") && content.includes("Field(")) { + const cls = content.match(/class\s+(\w+)\(.*BaseModel\)/); + return cls ? `Pydantic: ${cls[1]}` : "Pydantic model"; + } + // Celery + if (content.includes("@shared_task") || content.includes("@app.task")) { + const tasks = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; + return cap(tasks.length ? `Celery tasks: ${tasks.join(", ")}` : "Celery task"); + } + // Generic + const pyClass = content.match(/class\s+(\w+)/); + const funcs = (content.match(/def\s+(\w+)/g) || []).map(f => f.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; + if (pyClass && funcs.length > 0) return cap(funcs.length > 4 ? `${pyClass[1]}: ${funcs.slice(0, 4).join(", ")} + ${funcs.length - 4} more` : `${pyClass[1]}: ${funcs.join(", ")}`); + if (funcs.length > 0) return cap(funcs.slice(0, 4).join(", ")); + } + + // ─── Go ────────────────────────────────────────────────── + if (ext === ".go") { + const handlers = (content.match(/func\s+(\w+)\s*\(\s*\w+\s+http\.ResponseWriter/g) || []) + .map(m => m.match(/func\s+(\w+)/)?.[1]).filter(Boolean); + if (handlers.length) return cap(`HTTP handlers: ${handlers.slice(0, 5).join(", ")}`); + const iface = content.match(/type\s+(\w+)\s+interface\s*\{/); + if (iface) return `Interface: ${iface[1]}`; + const structM = content.match(/type\s+(\w+)\s+struct\s*\{/); + if (structM) return `Struct: ${structM[1]}`; + const funcs = (content.match(/^func\s+(\w+)/gm) || []).map(m => m.match(/func\s+(\w+)/)?.[1]).filter(n => n && n[0] === n[0].toUpperCase()) as string[]; + if (funcs.length) return cap(funcs.slice(0, 5).join(", ")); + } + + // ─── Rust ──────────────────────────────────────────────── + if (ext === ".rs") { + const structM = content.match(/pub\s+struct\s+(\w+)/); + if (structM) { + const methods = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean); + return cap(methods.length ? `${structM[1]}: ${methods.slice(0, 4).join(", ")}` : `Struct: ${structM[1]}`); + } + const traitM = content.match(/pub\s+trait\s+(\w+)/); + if (traitM) return `Trait: ${traitM[1]}`; + const enumM = content.match(/pub\s+enum\s+(\w+)/); + if (enumM) return `Enum: ${enumM[1]}`; + const fns = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean); + if (fns.length) return cap(fns.slice(0, 5).join(", ")); + } + + // ─── Java / Spring ─────────────────────────────────────── + if (ext === ".java") { + const cls = content.match(/(?:public\s+)?class\s+(\w+)/); + const className = cls?.[1] || basename.replace(".java", ""); + const annotations = (content.match(/@(RestController|Controller|Service|Repository|Component|Entity|Configuration)/g) || []).map(a => a.slice(1)); + const mappings = (content.match(/@(?:Get|Post|Put|Patch|Delete|Request)Mapping/g) || []).length; + if (mappings) return cap(`${annotations[0] || "Spring"}: ${className} (${mappings} endpoints)`); + if (annotations.length) return `${annotations[0]}: ${className}`; + if (content.includes("@Entity")) return `Entity: ${className}`; + const methods = (content.match(/public\s+(?:static\s+)?(?:\w+(?:<[\w,\s]+>)?)\s+(\w+)\s*\(/g) || []) + .map(m => m.match(/(\w+)\s*\(/)?.[1]).filter(n => n && n !== className) as string[]; + if (methods.length) return cap(`${className}: ${methods.slice(0, 4).join(", ")}`); + return className ? `Class: ${className}` : ""; + } + + // ─── Kotlin ────────────────────────────────────────────── + if (ext === ".kt" || ext === ".kts") { + const cls = content.match(/(?:data\s+)?class\s+(\w+)/); + if (content.match(/data\s+class/)) return `Data class: ${cls?.[1] || basename.replace(/\.kts?$/, "")}`; + if (content.includes("routing {")) return "Ktor routing"; + const fns = (content.match(/fun\s+(\w+)/g) || []).map(m => m.match(/fun\s+(\w+)/)?.[1]).filter(Boolean); + if (cls && fns.length) return cap(`${cls[1]}: ${fns.slice(0, 4).join(", ")}`); + if (fns.length) return cap(fns.slice(0, 5).join(", ")); + } + + // ─── C# / .NET ─────────────────────────────────────────── + if (ext === ".cs") { + const cls = content.match(/(?:public\s+)?(?:partial\s+)?class\s+(\w+)(?:\s*:\s*(\w+))?/); + const className = cls?.[1] || basename.replace(".cs", ""); + const parent = cls?.[2] || ""; + if (parent === "Controller" || parent === "ControllerBase" || content.includes("[ApiController]")) { + const actions = (content.match(/\[Http(Get|Post|Put|Patch|Delete)\]/g) || []).map(a => a.match(/Http(\w+)/)?.[1]).filter(Boolean); + return cap(actions.length ? `API Controller: ${className} (${[...new Set(actions)].join(", ")})` : `Controller: ${className}`); + } + if (parent === "DbContext" || content.includes("DbSet<")) { + const sets = (content.match(/DbSet<(\w+)>/g) || []).map(s => s.match(/<(\w+)>/)?.[1]).filter(Boolean); + return cap(sets.length ? `DbContext: ${sets.join(", ")}` : `DbContext: ${className}`); + } + return className ? `Class: ${className}` : ""; + } + + // ─── Ruby / Rails ──────────────────────────────────────── + if (ext === ".rb") { + const cls = content.match(/class\s+(\w+)(?:\s*<\s*(\w+(?:::\w+)?))?/); + const className = cls?.[1] || ""; + const parent = cls?.[2] || ""; + if (parent?.includes("Controller")) { + const actions = (content.match(/def\s+(index|show|new|create|edit|update|destroy|\w+)/g) || []) + .map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; + return cap(actions.length ? `Controller: ${actions.join(", ")}` : `Controller: ${className}`); + } + if (parent === "ApplicationRecord" || parent === "ActiveRecord::Base") return `Model: ${className}`; + if (basename.match(/^\d{14}_/)) { + const create = content.match(/create_table\s+:(\w+)/); + return create ? `Migration: create ${create[1]}` : "Database migration"; + } + const methods = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; + if (cls && methods.length) return cap(`${className}: ${methods.slice(0, 4).join(", ")}`); + } + + // ─── Swift ─────────────────────────────────────────────── + if (ext === ".swift") { + if (content.includes(": View") || content.includes("some View")) { + const name = content.match(/struct\s+(\w+)\s*:\s*View/); + return name ? `SwiftUI view: ${name[1]}` : "SwiftUI view"; + } + const proto = content.match(/protocol\s+(\w+)/); + if (proto) return `Protocol: ${proto[1]}`; + const struct = content.match(/(?:public\s+)?struct\s+(\w+)/); + const cls = content.match(/(?:public\s+)?class\s+(\w+)/); + const name = struct?.[1] || cls?.[1] || ""; + if (name) return `${struct ? "Struct" : "Class"}: ${name}`; + } + + // ─── Dart / Flutter ────────────────────────────────────── + if (ext === ".dart") { + if (content.includes("StatefulWidget") || content.includes("StatelessWidget")) { + const name = content.match(/class\s+(\w+)\s+extends\s+(?:Stateful|Stateless)Widget/); + return name ? `${content.includes("StatefulWidget") ? "Stateful" : "Stateless"} widget: ${name[1]}` : "Flutter widget"; + } + const cls = content.match(/class\s+(\w+)/); + if (cls) return `Class: ${cls[1]}`; + } + + // ─── Vue / Svelte / Astro ──────────────────────────────── + if (ext === ".vue") { + const name = content.match(/name:\s*['"]([^'"]+)['"]/); + const setup = content.includes(" + + diff --git a/crates/sabia-cli/src/web/mod.rs b/crates/sabia-cli/src/web/mod.rs new file mode 100644 index 0000000..807195b --- /dev/null +++ b/crates/sabia-cli/src/web/mod.rs @@ -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>, + running: Arc, +} + +#[derive(Default)] +struct AppState { + sessions: Mutex>, +} + +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, + token: Option, +} + +fn parse_request(stream: &mut TcpStream) -> std::io::Result { + 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, 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, 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, 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, name: &str) { + let mut sessions = state.sessions.lock().unwrap(); + if let Some(s) = sessions.remove(name) { + s.running.store(false, Ordering::SeqCst); + } +} diff --git a/crates/sabia-core/Cargo.toml b/crates/sabia-core/Cargo.toml new file mode 100644 index 0000000..72b642c --- /dev/null +++ b/crates/sabia-core/Cargo.toml @@ -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. diff --git a/crates/sabia-core/src/audit.rs b/crates/sabia-core/src/audit.rs new file mode 100644 index 0000000..ecbff7f --- /dev/null +++ b/crates/sabia-core/src/audit.rs @@ -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) -> Result { + 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::(&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 { + 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) -> Result> { + 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) + } +} diff --git a/crates/sabia-core/src/capture.rs b/crates/sabia-core/src/capture.rs new file mode 100644 index 0000000..6df6684 --- /dev/null +++ b/crates/sabia-core/src/capture.rs @@ -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 { + 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::() 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::() 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> { + 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 { + 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 +} diff --git a/crates/sabia-core/src/dot11.rs b/crates/sabia-core/src/dot11.rs new file mode 100644 index 0000000..d8b31f5 --- /dev/null +++ b/crates/sabia-core/src/dot11.rs @@ -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, + pub security: Option, + pub channel_ie: Option, +} + +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> { + 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 { + self.bssid.map(|m| mac_to_string(&m)) + } + + pub fn src_str(&self) -> Option { + 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 +} diff --git a/crates/sabia-core/src/error.rs b/crates/sabia-core/src/error.rs new file mode 100644 index 0000000..9b20a26 --- /dev/null +++ b/crates/sabia-core/src/error.rs @@ -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 = std::result::Result; diff --git a/crates/sabia-core/src/iface.rs b/crates/sabia-core/src/iface.rs new file mode 100644 index 0000000..62a1276 --- /dev/null +++ b/crates/sabia-core/src/iface.rs @@ -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, + 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> { + 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> { + Ok(list_all()?.into_iter().filter(|i| i.phy.is_some()).collect()) +} + +pub fn get(name: &str) -> Result { + 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 { + 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//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 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, + } +} diff --git a/crates/sabia-core/src/lib.rs b/crates/sabia-core/src/lib.rs new file mode 100644 index 0000000..8f64811 --- /dev/null +++ b/crates/sabia-core/src/lib.rs @@ -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}; diff --git a/crates/sabia-core/src/netlink.rs b/crates/sabia-core/src/netlink.rs new file mode 100644 index 0000000..cbf9235 --- /dev/null +++ b/crates/sabia-core/src/netlink.rs @@ -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 { + 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 { + 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> { + 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::() { + 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(()) +} diff --git a/crates/sabia-core/src/pcapwriter.rs b/crates/sabia-core/src/pcapwriter.rs new file mode 100644 index 0000000..a40fab5 --- /dev/null +++ b/crates/sabia-core/src/pcapwriter.rs @@ -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, +} + +impl PcapWriter { + pub fn create(path: impl AsRef) -> Result { + 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(()) + } +} diff --git a/crates/sabia-core/src/radiotap.rs b/crates/sabia-core/src/radiotap.rs new file mode 100644 index 0000000..c23e310 --- /dev/null +++ b/crates/sabia-core/src/radiotap.rs @@ -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: +//! + +#[derive(Debug, Clone, Copy, Default)] +pub struct RadiotapInfo { + pub freq_mhz: Option, + pub signal_dbm: Option, + 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 { + 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 { + 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, + } +} diff --git a/crates/sabia-core/src/scan.rs b/crates/sabia-core/src/scan.rs new file mode 100644 index 0000000..7e71960 --- /dev/null +++ b/crates/sabia-core/src/scan.rs @@ -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, + pub channel: Option, + pub security: Option, + pub last_signal_dbm: Option, + pub beacons_seen: u64, + pub clients: Vec, + pub last_seen_unix: u64, +} + +#[derive(Default)] +pub struct ScanTable { + aps: HashMap, +} + +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 { + 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(), + } +} diff --git a/crates/sabia-core/src/update.rs b/crates/sabia-core/src/update.rs new file mode 100644 index 0000000..916d14c --- /dev/null +++ b/crates/sabia-core/src/update.rs @@ -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, +} + +#[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, + pub update_available: bool, +} + +fn curl_text(url: &str) -> Result { + 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 { + 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 { + 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 { + 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() }) +}