Esqueleto inicial: sabia-core + sabia-cli + UI local, OpenWolf
- sabia-core: descoberta de interfaces, controle de modo monitor/canal via iw (ponte até ter nl80211 nativo), captura crua 802.11 via AF_PACKET (sem libpcap), parsing de radiotap e quadros de gerenciamento (beacon/probe/EAPOL), agregação em tabela de APs/ clientes, escrita de .pcap, trilha de auditoria encadeada por hash, auto-atualização via GitHub releases (curl, sem dependências de rede extras) - sabia-cli: `sabia iface`, `sabia scan`, `sabia update`, `sabia audit-verify`, `sabia ui` (servidor local + navegador, protegido por token, mesma filosofia do Guará) - OpenWolf inicializado para memória de projeto entre agentes
This commit is contained in:
commit
e989413018
76 files changed
+8386
No files matched your search
@@ -0,0 +1,165 @@
|
||||
import { normalizeSession } from "./session-state.js"
|
||||
import { gcSessionFiles as canonicalGC } from "./shared.js"
|
||||
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as crypto from "node:crypto"
|
||||
|
||||
export function getWolfDir(directory: string): string {
|
||||
return path.join(directory, ".wolf")
|
||||
}
|
||||
|
||||
export function wolfDirExists(directory: string): boolean {
|
||||
return fs.existsSync(getWolfDir(directory))
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return (
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
!Array.isArray(v) &&
|
||||
Object.getPrototypeOf(v) === Object.prototype
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fills missing keys in `loaded` from `defaults`.
|
||||
* Loaded values always win; defaults only fill gaps. Arrays and scalars
|
||||
* are replaced wholesale (not merged).
|
||||
*/
|
||||
function deepMergeDefaults<T>(defaults: T, loaded: T): T {
|
||||
if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded
|
||||
const result: Record<string, unknown> = { ...(defaults as Record<string, unknown>) }
|
||||
for (const key of Object.keys(loaded as Record<string, unknown>)) {
|
||||
const lv = (loaded as Record<string, unknown>)[key]
|
||||
const dv = (defaults as Record<string, unknown>)[key]
|
||||
if (isPlainObject(lv) && isPlainObject(dv)) {
|
||||
result[key] = deepMergeDefaults(dv, lv)
|
||||
} else {
|
||||
result[key] = lv
|
||||
}
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads JSON from `filePath`. If the file exists and parses, its values are
|
||||
* deep-merged over `fallback` so that missing nested keys fall back to the
|
||||
* provided defaults (loaded values always win). If the file is missing or
|
||||
* unparseable, `fallback` is returned as-is.
|
||||
*
|
||||
* This prevents `TypeError: Cannot read properties of undefined` when a
|
||||
* user's file predates a section a newer release reads (e.g. a pre-2.0
|
||||
* token-ledger.json without `lifetime`).
|
||||
*/
|
||||
export function readJSON<T>(filePath: string, fallback: T): T {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(raw) as T
|
||||
const value=deepMergeDefaults(fallback, parsed)
|
||||
return /[\\/]hooks[\\/]sessions[\\/][^\\/]+\.json$/.test(filePath) ? normalizeSession(value,path.basename(filePath,".json")) as T : value
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJSON(filePath: string, data: unknown): void {
|
||||
const dir = path.dirname(filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
|
||||
try {
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8")
|
||||
fs.renameSync(tmp, filePath)
|
||||
} catch {
|
||||
try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8") } catch {}
|
||||
try { fs.unlinkSync(tmp) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session state path, mirroring getSessionFilePath() in src/hooks/shared.ts
|
||||
* (same id validation, same layout).
|
||||
*
|
||||
* Issue #89, reported with PR #113 by @davdittrich, whose session-file GC is
|
||||
* adopted below.
|
||||
*
|
||||
* Every OpenCode handler receives a sessionId but they all used to persist to
|
||||
* one shared hooks/_session.json, so starting a second session in the same
|
||||
* project overwrote the first one's state and each later read or write from
|
||||
* either session mutated the survivor (#89). The legacy shared file is used
|
||||
* only when there is no usable id, so existing single-session installs keep
|
||||
* working.
|
||||
*/
|
||||
export function sessionFilePath(hooksDir: string, sessionId: string | undefined): string {
|
||||
if (typeof sessionId === "string" && /^[\w.-]{4,128}$/.test(sessionId)) {
|
||||
return path.join(hooksDir, "sessions", `${sessionId}.json`)
|
||||
}
|
||||
return path.join(hooksDir, "_session.json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete per-session state files older than maxAgeDays.
|
||||
*
|
||||
* Mirrors gcSessionFiles() in src/hooks/shared.ts. Splitting state per session
|
||||
* fixes cross-session contamination but creates one file per session forever,
|
||||
* so the directory needs the same bound the main hooks already apply.
|
||||
*/
|
||||
export function gcSessionFiles(hooksDir: string, maxAgeDays = 7): void {
|
||||
canonicalGC(maxAgeDays,path.dirname(hooksDir))
|
||||
}
|
||||
|
||||
export function readMarkdown(filePath: string): string {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function appendMarkdown(filePath: string, line: string): void {
|
||||
const dir = path.dirname(filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
const ok = withFileLock(filePath + ".lock", HOOK_LOCK_BUDGET_MS, () => { fs.appendFileSync(filePath, line, "utf-8"); return true })
|
||||
if (ok === null) {
|
||||
const pending = filePath + ".pending"
|
||||
fs.mkdirSync(pending, {recursive:true})
|
||||
fs.writeFileSync(path.join(pending, `${Date.now()}-${crypto.randomUUID()}.json`), JSON.stringify({line}), {flag:"wx",mode:0o600})
|
||||
}
|
||||
}
|
||||
|
||||
export function timeShort(): string {
|
||||
const d = new Date()
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function timestamp(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
export function estimateTokens(text: string, type: "code" | "prose" | "mixed" = "mixed"): number {
|
||||
const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75
|
||||
return Math.ceil(text.length / ratio)
|
||||
}
|
||||
|
||||
// Files whose contents (or content-derived descriptions) must never reach
|
||||
// anatomy.md / memory.md because they hold secrets (issue #54). Mirrors
|
||||
// isSensitiveFile in src/hooks/shared.ts.
|
||||
const SENSITIVE_EXTENSIONS = new Set([
|
||||
".pem", ".key", ".p8", ".p12", ".pfx", ".keystore", ".jks", ".ppk", ".kdbx", ".tfstate",
|
||||
])
|
||||
const SENSITIVE_BASENAMES = new Set([".npmrc", ".netrc", ".htpasswd", ".pgpass"])
|
||||
|
||||
export function isSensitiveFile(basename: string): boolean {
|
||||
const lower = basename.toLowerCase()
|
||||
if (lower === ".env" || lower.startsWith(".env.")) return true
|
||||
if (SENSITIVE_BASENAMES.has(lower)) return true
|
||||
const dot = lower.lastIndexOf(".")
|
||||
if (dot >= 0 && SENSITIVE_EXTENSIONS.has(lower.slice(dot))) return true
|
||||
if (/^id_(rsa|dsa|ecdsa|ed25519)/.test(lower)) return true
|
||||
if (lower.includes("credential") || /^secrets\.(json|ya?ml|toml)$/.test(lower)) return true
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user