Files
sabia/.opencode/plugin/openwolf/fs.ts
T
Lucas e989413018 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
2026-10-23 08:22:04 -03:00

165 lines
6.1 KiB
TypeScript

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
}