From d1b75ab79737275f49331969946633f0975e0d01 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 05:13:10 -0400 Subject: [PATCH 01/11] feat(skills): skill runtime and instruction resolver foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR A0.1 of the SC-A0 Skills & Health integration rescue. Recovers the skill and instruction subsystems from PR #71 (a868f7d0) onto current main (41a7e261) without importing PR #71's parallel doctor engine. PR #71 branched at b98ef26d (2026-08-12) and never saw PR #66 (27c100d8, 2026-08-14), which landed its own doctor v2 on main. Both declare doctor schema version 2 with incompatible payloads: main's health.ts models configured/reachable/verified as Axis objects with a "not-checked" state, PR #71's contracts.ts models them as plain booleans. A boolean cannot express "not checked", so in fast mode — which performs no network I/O — every remote axis would have to report false, which is indistinguishable from checked-and-failed. main's contract is both newer and safer, so it stays canonical and PR #71's is dropped rather than merged. This commit lands only the part of PR #71 that main has no equivalent of, and which turned out to apply to main unmodified: src/core/skills/* schema, digest, lock, trust, discovery, loader, resolver, policy, bounds, eval, session, settings, context packet, permission vocabulary src/core/instructions/* AGENTS.md discovery and resolution with provenance src/core/why_log.ts capability explanation log Capabilities, support bundle, and the skills/instructions doctor checks follow in A0.2; CLI wiring and packaged skill assets follow in A0.3. The full conflict matrix, including the four textual conflicts and the decision record for each contested surface, is in _loopstate/LOOP-01/sc-a0-2026-08-19/AUDIT-ARTIFACT.md. Gates at this commit, run against this exact tree: npm run typecheck exit 0 npm test 1006 pass / 0 fail (baseline on 41a7e261 was 922/0) --- .../sc-a0-2026-08-19/AUDIT-ARTIFACT.md | 193 +++++++++ .../instructions/instruction_discovery.ts | 205 ++++++++++ src/core/instructions/instruction_resolver.ts | 159 ++++++++ src/core/instructions/instruction_types.ts | 69 ++++ src/core/skills/context_packet.ts | 83 ++++ src/core/skills/permission_vocabulary.ts | 67 ++++ src/core/skills/skill_bounds.ts | 37 ++ src/core/skills/skill_digest.ts | 124 ++++++ src/core/skills/skill_discovery.ts | 183 +++++++++ src/core/skills/skill_errors.ts | 45 +++ src/core/skills/skill_eval.ts | 203 ++++++++++ src/core/skills/skill_loader.ts | 86 ++++ src/core/skills/skill_lock.ts | 120 ++++++ src/core/skills/skill_policy.ts | 129 ++++++ src/core/skills/skill_resolver.ts | 164 ++++++++ src/core/skills/skill_schema.ts | 368 ++++++++++++++++++ src/core/skills/skill_session.ts | 115 ++++++ src/core/skills/skill_settings.ts | 80 ++++ src/core/skills/skill_trust.ts | 136 +++++++ src/core/skills/skill_types.ts | 84 ++++ src/core/why_log.ts | 45 +++ test/instruction_resolver.test.ts | 192 +++++++++ test/skill_digest.test.ts | 162 ++++++++ test/skill_discovery_resolution.test.ts | 252 ++++++++++++ test/skill_lazy_loading.test.ts | 146 +++++++ test/skill_performance.test.ts | 80 ++++ test/skill_policy_packet.test.ts | 199 ++++++++++ test/skill_schema.test.ts | 219 +++++++++++ test/skill_trust_lock.test.ts | 198 ++++++++++ 29 files changed, 4143 insertions(+) create mode 100644 _loopstate/LOOP-01/sc-a0-2026-08-19/AUDIT-ARTIFACT.md create mode 100644 src/core/instructions/instruction_discovery.ts create mode 100644 src/core/instructions/instruction_resolver.ts create mode 100644 src/core/instructions/instruction_types.ts create mode 100644 src/core/skills/context_packet.ts create mode 100644 src/core/skills/permission_vocabulary.ts create mode 100644 src/core/skills/skill_bounds.ts create mode 100644 src/core/skills/skill_digest.ts create mode 100644 src/core/skills/skill_discovery.ts create mode 100644 src/core/skills/skill_errors.ts create mode 100644 src/core/skills/skill_eval.ts create mode 100644 src/core/skills/skill_loader.ts create mode 100644 src/core/skills/skill_lock.ts create mode 100644 src/core/skills/skill_policy.ts create mode 100644 src/core/skills/skill_resolver.ts create mode 100644 src/core/skills/skill_schema.ts create mode 100644 src/core/skills/skill_session.ts create mode 100644 src/core/skills/skill_settings.ts create mode 100644 src/core/skills/skill_trust.ts create mode 100644 src/core/skills/skill_types.ts create mode 100644 src/core/why_log.ts create mode 100644 test/instruction_resolver.test.ts create mode 100644 test/skill_digest.test.ts create mode 100644 test/skill_discovery_resolution.test.ts create mode 100644 test/skill_lazy_loading.test.ts create mode 100644 test/skill_performance.test.ts create mode 100644 test/skill_policy_packet.test.ts create mode 100644 test/skill_schema.test.ts create mode 100644 test/skill_trust_lock.test.ts diff --git a/_loopstate/LOOP-01/sc-a0-2026-08-19/AUDIT-ARTIFACT.md b/_loopstate/LOOP-01/sc-a0-2026-08-19/AUDIT-ARTIFACT.md new file mode 100644 index 0000000..8965c29 --- /dev/null +++ b/_loopstate/LOOP-01/sc-a0-2026-08-19/AUDIT-ARTIFACT.md @@ -0,0 +1,193 @@ +# LOOP-01 / Lane SC-A0 — Skills & Health Integration Rescue — Semantic Conflict Matrix + +run_id: sc-a0-2026-08-19 +date: 2026-08-19 +target: AetherAI3/aether-agent +branch: supercluster/a0-skills-health-integration +base_sha: 41a7e261c7fef6cefabbb83c62f70646fde63529 (origin/main, fetched this run) +pr71_head: a868f7d02008e04f2c0c8ce2b94b03ccba7c2a70 +merge_base: b98ef26d16daf61a32a6c0ca437172d794b2efe1 + +## Verdict + +BLOCKED-PENDING-IMPLEMENTATION — intake and contract complete; no code written yet. + +## Fetched baseline (not inherited from the prompt) + +| Fact | Prompt audit ref | Actually fetched 2026-08-19 | Status | +| ---- | ---------------- | --------------------------- | ------ | +| origin/main | 41a7e261… | 41a7e261… | confirmed | +| PR #71 head | a868f7d0… | a868f7d0… | confirmed | +| PR #71 merge base | b98ef26d… | b98ef26d… | confirmed | +| PR #71 state | (unstated) | OPEN, **mergeable=CONFLICTING** | new | +| PR #71 size | (unstated) | 100 files, +9157 / -343, 16 commits | new | +| PR #71 CI | (unstated) | **no statusCheckRollup entries at all** | new | + +## Root cause of the conflict + +`b98ef26d` (2026-08-12) is the last commit both sides share. + +* **main** then took `27c100d8` (2026-08-14 **14:42**) — PR #66 "durable media output + history, one safe opener, and aether doctor v2". +* **PR #71** branched at `b98ef26d` and its last commit is `a868f7d0` + (2026-08-14 **22:49**) — 8 hours later on the clock, but authored against the + 08-12 tree and therefore **blind to PR #66**. + +Both independently built something called "doctor v2" in the same 48 hours. +This is not a rebase problem. It is two competing report contracts that both +declare schema version 2. + +## Textual conflicts (4 files — the small part) + + COMMANDS.md 1 hunk + src/commands/doctor.ts 3 hunks + src/core/diagnostics.ts 1 hunk (whole file) + test/diagnostics.test.ts 3 hunks + +## Semantic conflict matrix (the real part) + +### C1 — the doctor report contract (highest severity) + +| | current main | PR #71 | +| --- | --- | --- | +| home | `src/core/health.ts` (185L) | `src/core/diagnostics/contracts.ts` (72L) | +| report type | `HealthReport`, camelCase | `DoctorReportV2`, snake_case | +| version claim | `DOCTOR_SCHEMA_VERSION = 2` | `schema_version: 2` | +| configured / reachable / verified | **`Axis` objects**, `state` in {yes,no,unknown,na,not-checked} + evidence | **plain `boolean`** | +| mode | `fast \| live \| fix` | `fast \| network \| live \| fix` | +| severity | `info \| warning \| error` | `info \| warning \| critical` | +| redaction | `redact()` / `redactCheck()` | structural claim `evidence:{metadata_only:true}` | +| newer? | **yes** (2026-08-14 14:42, on main) | no (authored against 08-12 base) | + +**Which is safer:** main's. A `boolean verified` cannot express *not-checked*. +In fast mode — which by contract performs no network I/O — every remote axis is +unchecked, so PR #71's shape must emit `verified:false`, which is indistinguishable +from *checked and failed*. main's `not-checked` axis state keeps those distinct. + +This is exactly the frozen invariant the lane is required to preserve: +"Keep configured, reachable, and verified semantically distinct" and +"A doctor probe must not report verified unless that exact path was exercised +in the current run." + +**Chosen contract:** main's `health.ts` 3-axis model is canonical. +PR #71's `DoctorCheckV2` boolean triple is **dropped**, not merged. Shipping both +would put two mutually-incompatible payloads behind one `schema_version: 2`. + +**Test proving the choice:** a fast-mode run must emit `state:"not-checked"` on every +remote axis and must not emit any boolean-shaped `verified` field. Mutating +`notChecked()` to return `axis("no")` must fail that test. + +### C2 — `--deep` + +| | current main | PR #71 | +| --- | --- | --- | +| meaning | read-only alias of the fast report; prints where the new behaviour lives | **alias of `--network`** | + +PR #71's aliasing silently adds network I/O to a flag that is read-only today. +main's source comment states the reasoning explicitly and refuses it. +Lane contract says: "Do not silently make `--deep` mutate, spend, or replace `--live`." + +**Chosen contract:** main's. `--deep` stays a read-only alias of fast. +`--network` lands as a **new, explicitly-named** bounded network-inspection mode. +`configured` / `reachable` / `verified` stay distinct across all four modes. + +### C3 — the diagnostics engine + +| | current main | PR #71 | +| --- | --- | --- | +| shape | `diagnostics.ts` 476L, monolithic | `diagnostics.ts` to 35L re-export barrel over `src/core/diagnostics/*` (17 files, 1325L) | +| live proof | `doctor_live.ts` **991L** | `diagnostics/dev_session_live.ts` 154L | +| repair | `doctor_repair.ts` 378L | `diagnostics/repair.ts` 267L | + +PR #71's *decomposition* is better than main's monolith. PR #71's *content* is +substantially thinner and older — its live engine is 15% the size of main's. + +**Chosen contract:** keep main's engine content; adopt PR #71's module decomposition +as a follow-on refactor **only** once behaviour is proven equivalent (LOOP-13), not +as part of this rescue. PR #71's re-implementations of checks main already has +(`auth, backend, mcp, memory, persistence, runtime, tools, transport, workspace`) +are dropped as duplicates. + +### C4 — what in PR #71 is genuinely new and must survive + +Not superseded by anything on main. This is the payload the rescue exists for: + + src/core/skills/* 20 files — schema, digest, lock, trust, discovery, + loader, resolver, policy, bounds, eval, session, + settings, context packet, permission vocabulary + src/core/instructions/* 3 files — AGENTS.md discovery + resolver + provenance + src/core/capabilities.ts capability matrix + src/commands/capabilities.ts `aether capabilities` + `/why` + src/core/support_bundle.ts redacted self-verifying bundle + src/core/redaction.ts + src/core/tar.ts + src/core/why_log.ts + src/commands/skills.ts `aether skills` CLI family + src/skills/builtin/* 6 built-in skills + eval cases (18 asset files) + scripts/copy-skill-assets.ts package asset copy + src/core/diagnostics/skills.ts NEW check — no main equivalent + src/core/diagnostics/instructions.ts NEW check — no main equivalent + +`diagnostics/skills.ts` and `diagnostics/instructions.ts` bind to PR #71's +`CheckSpec` only thinly (`{id, category, mode, severity, repairId, run(deps)}`). +main's `DiagnosticCheckSpec` + `CheckOutcome` carries the same fields plus +`title` and `remediation`. The port is a shape translation, not a rewrite. + +### C5 — additive surfaces worth keeping from PR #71 + +`--schema v2` JSON selection, `renderDoctorJUnit`, and `toV1Report` are useful and +have no main equivalent — but they are written against `DoctorReportV2`. They are +**deferred to PR A0.3** and must be re-targeted at `HealthReport`, or dropped. +They are not worth resurrecting the dead contract for. + +## Resulting lane shape + +Rebasing PR #71 as authored is the wrong move: it would land a second doctor v2, +a second wire contract under the same version number, and 9 duplicate checks. + +Reconstruct instead, as a stack: + + PR A0.1 skill + instruction foundation src/core/skills, src/core/instructions, tests + PR A0.2 capabilities, doctor integration, capabilities, support bundle, redaction, tar, + repair and support bundle why_log; skills+instructions checks ported + onto health.ts CheckOutcome + PR A0.3 public CLI wiring and package assets aether skills, /skills, capabilities cmd, + builtin assets, copy-skill-assets, docs + +Net effect: PR #71's 100 files drop to roughly 60; the wire-contract collision +disappears; main's newer live/repair engine is preserved intact; PR #66's +media-history and safe-opener work is untouched. + +## Findings + +| id | severity | domain | evidence | description | status | +| -- | -------- | ------ | -------- | ----------- | ------ | +| A0-F1 | CRITICAL | contract | health.ts:16 vs diagnostics/contracts.ts:26 | Two incompatible payloads both declare doctor schema version 2 | contract chosen (C1) | +| A0-F2 | HIGH | false-green | diagnostics/contracts.ts:16-18 | `verified: boolean` cannot express not-checked; fast mode must then emit a value indistinguishable from checked-and-failed | contract chosen (C1) | +| A0-F3 | HIGH | compat | PR71 doctor.ts header vs main doctor.ts:5-7 | PR #71 silently repoints read-only `--deep` at network-performing `--network` | contract chosen (C2) | +| A0-F4 | MEDIUM | duplication | diagnostics/{auth,backend,mcp,memory,persistence,runtime,tools,transport,workspace}.ts | 9 checks re-implemented that main already has | drop as duplicates (C3) | +| A0-F5 | MEDIUM | regression-risk | doctor_live.ts 991L vs dev_session_live.ts 154L | Naive `theirs` resolution would silently replace main's live engine with a 6x thinner one | drop PR71 side (C3) | +| A0-F6 | HIGH | process | `gh pr view 71 --json statusCheckRollup` returns empty | PR #71 has **no CI at all**; its green status cannot be inherited | open | +| A0-F7 | MEDIUM | metadata | package.json repository/bugs point at `DBarr3/aether-agent` | Stale owner; canonical repo is `AetherAI3/aether-agent` | deferred to SC-INT | + +## Tests executed + +| command | result | commit | notes | +| ------- | ------ | ------ | ----- | +| `git merge --no-commit --no-ff a868f7d0` | conflict, 4 files | 41a7e26 | intake only; aborted, tree clean | + +No build/typecheck/test gate has been run yet at this commit. Nothing is claimed green. + +## Deferred items + +* `--schema v2` / JUnit renderer re-targeted at `HealthReport` (PR A0.3 or dropped) +* Adopting PR #71's module decomposition for main's engine (LOOP-13, behaviour-equivalence-gated) +* `package.json` owner metadata (SC-INT) + +## Merge dependencies + +SC-A1 and SC-A5 must start from the accepted A0 integration head — both touch +`src/core/stream.ts`, which PR #71 also modifies. + +## Recommended next loops + +LOOP-06 (command truth for the reconciled doctor flag set), then LOOP-07 +(support-bundle + package asset safety), then LOOP-11/12 on trust and redaction. diff --git a/src/core/instructions/instruction_discovery.ts b/src/core/instructions/instruction_discovery.ts new file mode 100644 index 0000000..ef36a74 --- /dev/null +++ b/src/core/instructions/instruction_discovery.ts @@ -0,0 +1,205 @@ +// Bounded, read-only discovery of instruction files. +// +// Reads only well-known paths inside the project root (plus the user-level +// Aether instruction file), never follows a symlink out of the project, never +// fetches includes, and caps file size and source count honestly. + +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { createHash } from "node:crypto"; +import { configDir } from "../config.js"; +import { SKILL_BOUNDS } from "../skills/skill_bounds.js"; +import type { InstructionSource, InstructionSourceKind } from "./instruction_types.js"; + +function sha256(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +interface ReadOutcome { + content?: string; + status: "ok" | "truncated" | "invalid-encoding"; + sizeBytes: number; + reason?: string; +} + +/** UTF-8 read with byte cap; a replacement-char-dense file is treated as binary. */ +function readInstructionFile(path: string): ReadOutcome { + let bytes: Buffer; + try { + const stat = lstatSync(path); + if (!stat.isFile()) return { status: "invalid-encoding", sizeBytes: 0, reason: "not a regular file" }; + bytes = readFileSync(path); + } catch { + return { status: "invalid-encoding", sizeBytes: 0, reason: "unreadable" }; + } + if (bytes.includes(0)) return { status: "invalid-encoding", sizeBytes: bytes.length, reason: "binary content" }; + const truncated = bytes.length > SKILL_BOUNDS.maxInstructionFileBytes; + const slice = truncated ? bytes.subarray(0, SKILL_BOUNDS.maxInstructionFileBytes) : bytes; + const text = slice.toString("utf8"); + const replacementDensity = (text.match(/�/g)?.length ?? 0) / Math.max(1, text.length); + if (replacementDensity > 0.01) return { status: "invalid-encoding", sizeBytes: bytes.length, reason: "invalid encoding" }; + return { content: text, status: truncated ? "truncated" : "ok", sizeBytes: bytes.length }; +} + +/** True when `path`'s real location stays inside the real project root. */ +function staysInside(projectRoot: string, path: string): boolean { + try { + const realRoot = realpathSync(projectRoot); + const real = realpathSync(path); + return real === realRoot || real.startsWith(realRoot + sep); + } catch { + return false; + } +} + +interface DiscoveredFile { + kind: InstructionSourceKind; + path: string; + scopeDir: string; + globs: readonly string[] | null; + warnings: string[]; +} + +/** Parse the minimal Cursor .mdc frontmatter we support: a `globs:` line. */ +export function parseCursorGlobs(content: string): { globs: readonly string[] | null; warnings: string[]; body: string } { + const warnings: string[] = []; + if (!content.startsWith("---")) return { globs: null, warnings, body: content }; + const end = content.indexOf("\n---", 3); + if (end < 0) return { globs: null, warnings: ["unterminated frontmatter — rule applied to whole project"], body: content }; + const frontmatter = content.slice(3, end); + const body = content.slice(end + 4); + const globLine = frontmatter.split("\n").map((line) => line.trim()).find((line) => line.startsWith("globs:")); + if (!globLine) return { globs: null, warnings, body }; + const value = globLine.slice("globs:".length).trim(); + if (!value) return { globs: null, warnings, body }; + if (value.startsWith("[") || value.includes("{")) { + // YAML flow lists / brace expansion are outside the supported subset. + warnings.push("unsupported globs syntax '" + value.slice(0, 40) + "' — rule NOT applied (would otherwise apply globally)"); + return { globs: [], warnings, body }; + } + const globs = value.split(",").map((glob) => glob.trim()).filter((glob) => glob.length > 0); + for (const glob of globs) { + if (!/^[\w@./*?-]+$/.test(glob)) { + warnings.push("unsupported glob '" + glob + "' — rule NOT applied"); + return { globs: [], warnings, body }; + } + } + return { globs: globs.length ? globs : null, warnings, body }; +} + +/** Locate nested AGENTS.md files, bounded by depth; skips dot/vendor dirs. */ +function findNestedAgents(projectRoot: string): string[] { + const found: string[] = []; + const skip = new Set(["node_modules", "dist", "build", "vendor", "target", ".git"]); + const walk = (dir: string, depth: number): void => { + if (depth > SKILL_BOUNDS.maxNestedInstructionDepth) return; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries.sort()) { + if (entry.startsWith(".") || skip.has(entry)) continue; + const full = join(dir, entry); + let isDirectory = false; + try { + isDirectory = lstatSync(full).isDirectory(); + } catch { + continue; + } + if (isDirectory) { + const nested = join(full, "AGENTS.md"); + if (existsSync(nested)) found.push(nested); + walk(full, depth + 1); + } + } + }; + walk(projectRoot, 1); + return found; +} + +export function discoverInstructionSources(projectRoot: string): { + sources: InstructionSource[]; + skipped: { path: string; reason: string }[]; +} { + const root = resolve(projectRoot); + const candidates: DiscoveredFile[] = []; + + const addIfPresent = (kind: InstructionSourceKind, path: string, scopeDir = ""): void => { + if (existsSync(path)) candidates.push({ kind, path, scopeDir, globs: null, warnings: [] }); + }; + + addIfPresent("aether-project", join(root, ".aether", "instructions.md")); + addIfPresent("agents-root", join(root, "AGENTS.md")); + for (const nested of findNestedAgents(root)) { + const scopeDir = relative(root, join(nested, "..")).split(sep).join("/"); + candidates.push({ kind: "agents-nested", path: nested, scopeDir, globs: null, warnings: [] }); + } + addIfPresent("aether-user", join(configDir(), "instructions.md")); + addIfPresent("claude", join(root, "CLAUDE.md")); + addIfPresent("gemini", join(root, "GEMINI.md")); + addIfPresent("copilot", join(root, ".github", "copilot-instructions.md")); + + const cursorRules = join(root, ".cursor", "rules"); + if (existsSync(cursorRules)) { + let entries: string[] = []; + try { + entries = readdirSync(cursorRules).filter((entry) => entry.endsWith(".mdc")).sort(); + } catch { + entries = []; + } + for (const entry of entries) { + candidates.push({ kind: "cursor-rule", path: join(cursorRules, entry), scopeDir: "", globs: null, warnings: [] }); + } + } + + const sources: InstructionSource[] = []; + const skipped: { path: string; reason: string }[] = []; + + for (const candidate of candidates) { + if (sources.length >= SKILL_BOUNDS.maxInstructionSources) { + skipped.push({ path: candidate.path, reason: "instruction source cap (" + SKILL_BOUNDS.maxInstructionSources + ") reached" }); + continue; + } + const insideProject = candidate.kind === "aether-user" || staysInside(root, candidate.path); + if (!insideProject) { + skipped.push({ path: candidate.path, reason: "symlink escapes the project root" }); + continue; + } + const read = readInstructionFile(candidate.path); + if (read.content == null) { + skipped.push({ path: candidate.path, reason: read.reason ?? "unreadable" }); + continue; + } + let content = read.content; + let globs: readonly string[] | null = candidate.globs; + const warnings = [...candidate.warnings]; + let parseStatus: InstructionSource["parseStatus"] = read.status === "truncated" ? "truncated" : "ok"; + if (read.status === "truncated") warnings.push("file exceeds " + SKILL_BOUNDS.maxInstructionFileBytes + " bytes — truncated"); + if (candidate.kind === "cursor-rule") { + const parsed = parseCursorGlobs(content); + content = parsed.body; + globs = parsed.globs; + warnings.push(...parsed.warnings); + if (parsed.globs !== null && parsed.globs.length === 0) parseStatus = "unsupported-syntax"; + } + const displayPath = candidate.kind === "aether-user" + ? candidate.path + : relative(root, candidate.path).split(sep).join("/"); + sources.push({ + kind: candidate.kind, + path: candidate.path, + displayPath, + scopeDir: candidate.scopeDir, + globs, + sha256: sha256(content), + sizeBytes: read.sizeBytes, + content, + parseStatus, + warnings, + }); + } + + return { sources, skipped }; +} diff --git a/src/core/instructions/instruction_resolver.ts b/src/core/instructions/instruction_resolver.ts new file mode 100644 index 0000000..ff7600f --- /dev/null +++ b/src/core/instructions/instruction_resolver.ts @@ -0,0 +1,159 @@ +// InstructionResolver — precedence, per-file applicability, conflict detection. +// Pure over discovered sources; no filesystem access here. + +import { INSTRUCTION_PRECEDENCE, type InstructionConflict, type InstructionGraph, type InstructionSource } from "./instruction_types.js"; +import { discoverInstructionSources } from "./instruction_discovery.js"; + +/** Cursor-subset glob → RegExp ( ** , * , ? only — discovery rejected the rest). */ +function globToRegExp(glob: string): RegExp { + let pattern = ""; + for (let index = 0; index < glob.length; index++) { + const char = glob[index]; + if (char === "*") { + if (glob[index + 1] === "*") { pattern += ".*"; index++; } + else pattern += "[^/]*"; + } else if (char === "?") { + pattern += "[^/]"; + } else if (char != null) { + pattern += char.replace(/[.+^${}()|[\]\\]/, "\\$&"); + } + } + return new RegExp("^" + pattern + "$"); +} + +/** Does one source apply to a project-relative file path (posix separators)? */ +export function sourceAppliesTo(source: InstructionSource, relativePath: string | null): boolean { + if (source.parseStatus === "unsupported-syntax") return false; + if (relativePath == null) { + // No active file: subtree- or glob-scoped sources do not apply globally. + return source.scopeDir === "" && source.globs == null; + } + if (source.scopeDir !== "" && relativePath !== source.scopeDir && !relativePath.startsWith(source.scopeDir + "/")) { + return false; + } + if (source.globs != null) { + return source.globs.some((glob) => globToRegExp(glob).test(relativePath)); + } + return true; +} + +/** Applicable sources for a path, highest precedence first; nearer nested AGENTS.md outranks farther. */ +export function applicableSources( + sources: readonly InstructionSource[], + relativePath: string | null, +): InstructionSource[] { + return sources + .filter((source) => sourceAppliesTo(source, relativePath)) + .sort((a, b) => { + const rank = INSTRUCTION_PRECEDENCE[b.kind] - INSTRUCTION_PRECEDENCE[a.kind]; + if (rank !== 0) return rank; + // Deeper nested scope is "nearer" and wins between two nested AGENTS.md. + return b.scopeDir.length - a.scopeDir.length; + }); +} + +const NPM_TEST_PATTERN = /(?:^|[`\s])((?:npm|pnpm|yarn)\s+(?:run\s+)?[\w:.-]*test[\w:.-]*)/gim; +const RUNNER_PATTERN = /(?:^|[`\s])(pytest|go\s+test|cargo\s+test)/gim; +/** An argument token worth keeping: a flag, a path, or a scoped target — + * ordinary prose words ("Never", "here") do not qualify, which keeps a + * greedy match from swallowing the rest of a sentence. */ +const ARG_TOKEN = /^(?:-{1,2}[\w=:.\/-]+|[\w-]*[\/.:][\w\/.:=-]*)$/; + +/** Extract declared test commands — the highest-signal conflict class. */ +export function extractTestCommands(content: string): string[] { + const commands = new Set(); + for (const match of content.matchAll(NPM_TEST_PATTERN)) { + const command = match[1]?.trim().replace(/[`.,;]+$/, ""); + if (command) commands.add(command); + } + for (const match of content.matchAll(RUNNER_PATTERN)) { + const runner = match[1]; + if (!runner || match.index == null) continue; + const start = match.index + match[0].length; + const tail = content.slice(start, start + 200).trimStart(); + const parts: string[] = [runner.replace(/\s+/g, " ")]; + for (const token of tail.split(/\s+/)) { + const clean = token.replace(/[`.,;]+$/, ""); + if (!clean || !ARG_TOKEN.test(clean)) break; + parts.push(clean); + if (clean !== token) break; // sentence punctuation ends the command + } + commands.add(parts.join(" ")); + } + return [...commands]; +} + +export function detectConflicts(ordered: readonly InstructionSource[]): InstructionConflict[] { + const conflicts: InstructionConflict[] = []; + const entries: { source: InstructionSource; value: string }[] = []; + for (const source of ordered) { + for (const command of extractTestCommands(source.content)) { + entries.push({ source, value: command }); + } + } + const distinct = new Set(entries.map((entry) => entry.value)); + if (distinct.size > 1 && entries.length > 1) { + const winner = entries[0]; + if (winner) { + conflicts.push({ + topic: "test command", + entries, + effective: winner.value, + reason: sourceLabel(winner.source) + " has higher precedence", + }); + } + } + return conflicts; +} + +export function sourceLabel(source: InstructionSource): string { + switch (source.kind) { + case "aether-project": return "canonical Aether project instruction (" + source.displayPath + ")"; + case "agents-root": return "root AGENTS.md"; + case "agents-nested": return "nested " + source.displayPath; + case "aether-user": return "user-level Aether instruction"; + default: return "compatibility import (" + source.displayPath + ")"; + } +} + +/** Build the full graph for a project: discovery + global-scope conflict pass. */ +export function resolveInstructionGraph(projectRoot: string): InstructionGraph { + const { sources, skipped } = discoverInstructionSources(projectRoot); + const ordered = applicableSources(sources, null); + return { sources, conflicts: detectConflicts(ordered), skipped }; +} + +export const INSTRUCTION_CONTEXT_CONTRACT_VERSION = 1; + +export interface InstructionContextSource { + kind: string; + path: string; + scope: string; + digest: string; + content: string; +} + +export interface InstructionContextPacket { + contract_version: number; + sources: readonly InstructionContextSource[]; +} + +/** + * Transport packet for the brain. Provenance rides with every source so the + * model and the UI can attribute guidance; content is data, not system policy. + */ +export function buildInstructionContextPacket( + sources: readonly InstructionSource[], + relativePath: string | null, +): InstructionContextPacket { + return { + contract_version: INSTRUCTION_CONTEXT_CONTRACT_VERSION, + sources: applicableSources(sources, relativePath).map((source) => ({ + kind: source.kind, + path: source.displayPath, + scope: source.scopeDir === "" ? "project" : source.scopeDir, + digest: "sha256:" + source.sha256, + content: source.content, + })), + }; +} diff --git a/src/core/instructions/instruction_types.ts b/src/core/instructions/instruction_types.ts new file mode 100644 index 0000000..e5d182b --- /dev/null +++ b/src/core/instructions/instruction_types.ts @@ -0,0 +1,69 @@ +// Typed boundary for instruction-file discovery and resolution. +// +// Instruction files are TEXT GUIDANCE with visible provenance — never +// executable configuration. Nothing here runs commands, follows links, +// grants permissions, or imports secrets. + +export type InstructionSourceKind = + | "aether-project" // .aether/instructions.md — canonical project instruction + | "agents-root" // AGENTS.md at the project root + | "agents-nested" // AGENTS.md in a subdirectory (applies to its subtree) + | "aether-user" // user-level Aether instructions (/instructions.md) + | "claude" // CLAUDE.md compatibility import + | "gemini" // GEMINI.md compatibility import + | "copilot" // .github/copilot-instructions.md compatibility import + | "cursor-rule"; // .cursor/rules/*.mdc compatibility import + +/** + * Precedence rank — higher wins on conflict (operator turn and active skill + * outrank all files and live outside this module). A lower-precedence source + * may add non-conflicting guidance but never erases a higher one. + */ +export const INSTRUCTION_PRECEDENCE: Readonly> = { + "agents-nested": 60, + "aether-project": 50, + "agents-root": 40, + "aether-user": 30, + "claude": 20, + "gemini": 19, + "copilot": 18, + "cursor-rule": 17, +}; + +export interface InstructionSource { + kind: InstructionSourceKind; + /** Absolute path on disk. */ + path: string; + /** Path relative to the project root where applicable, else the absolute path. */ + displayPath: string; + /** + * Directory subtree (relative to project root, "" = whole project) the + * source applies to. Nested AGENTS.md applies only inside its directory. + */ + scopeDir: string; + /** Cursor rules: parsed glob patterns; null = applies to whole scope. */ + globs: readonly string[] | null; + /** sha256 of the raw bytes — provenance for the context drawer / doctor. */ + sha256: string; + sizeBytes: number; + content: string; + parseStatus: "ok" | "truncated" | "unsupported-syntax" | "invalid-encoding"; + /** Honest warnings, e.g. unsupported Cursor matching syntax. */ + warnings: readonly string[]; +} + +export interface InstructionConflict { + /** e.g. "test command" */ + topic: string; + entries: readonly { source: InstructionSource; value: string }[]; + /** The winning value after precedence. */ + effective: string; + reason: string; +} + +export interface InstructionGraph { + sources: readonly InstructionSource[]; + conflicts: readonly InstructionConflict[]; + /** Sources discovered but skipped, with a visible reason (over cap, bad encoding). */ + skipped: readonly { path: string; reason: string }[]; +} diff --git a/src/core/skills/context_packet.ts b/src/core/skills/context_packet.ts new file mode 100644 index 0000000..90a15cf --- /dev/null +++ b/src/core/skills/context_packet.ts @@ -0,0 +1,83 @@ +// Typed skill context packet — what actually rides to the brain (local or +// hosted). Bounded, sanitized for transport, inspectable before delegation. +// Instruction/resource CONTENT is never logged (session_log scrubbing applies). + +import { SKILL_BOUNDS } from "./skill_bounds.js"; +import { SkillError } from "./skill_errors.js"; +import type { LoadedSkill } from "./skill_types.js"; + +export const SKILL_CONTEXT_CONTRACT_VERSION = 1; + +export interface SkillContextResource { + name: string; + digest: string; + content: string; +} + +export interface SkillContextEntry { + id: string; + version: string; + digest: string; + scope: string; + invocation: "explicit" | "automatic"; + instructions: string; + resources: readonly SkillContextResource[]; + tool_policy: { allowed: readonly string[] }; + permission_policy: { requires: readonly string[] }; +} + +export interface SkillContextPacket { + contract_version: number; + skills: readonly SkillContextEntry[]; +} + +/** Rough token estimate: 4 bytes per token, the standard planning heuristic. */ +export function approximateTokens(bytes: number): number { + return Math.ceil(bytes / 4); +} + +/** + * Build the packet from loaded skills. Enforces the per-turn skill count and + * aggregate token budget — exceeding either refuses rather than clipping + * silently (an explicit override raises the budget upstream, not here). + */ +export function buildSkillContextPacket(skills: readonly LoadedSkill[]): SkillContextPacket { + if (skills.length > SKILL_BOUNDS.maxSkillsPerTurn) { + throw new SkillError({ + code: "skill.context_budget_exceeded", + detail: skills.length + " skills exceed the per-turn limit of " + SKILL_BOUNDS.maxSkillsPerTurn, + }); + } + let totalBytes = 0; + const entries: SkillContextEntry[] = skills.map((skill) => { + totalBytes += skill.loadedBytes; + return { + id: skill.descriptor.id, + version: skill.descriptor.version, + digest: "sha256:" + skill.descriptor.sha256, + scope: skill.descriptor.scope, + invocation: skill.invocation, + instructions: sanitizeForTransport(skill.instructions), + resources: skill.resources.map((resource) => ({ + name: resource.name, + digest: "sha256:" + resource.sha256, + content: sanitizeForTransport(resource.content), + })), + tool_policy: { allowed: skill.descriptor.manifest.tools.allowed }, + permission_policy: { requires: skill.descriptor.manifest.permissions.requires }, + }; + }); + const totalTokens = approximateTokens(totalBytes); + if (totalTokens > SKILL_BOUNDS.maxLoadedSkillTokens) { + throw new SkillError({ + code: "skill.context_budget_exceeded", + detail: "~" + totalTokens + " tokens of skill context exceed the budget of " + SKILL_BOUNDS.maxLoadedSkillTokens, + }); + } + return { contract_version: SKILL_CONTEXT_CONTRACT_VERSION, skills: entries }; +} + +/** Strip control characters that could corrupt SSE/JSON transport; keep \n and \t. */ +export function sanitizeForTransport(text: string): string { + return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""); +} diff --git a/src/core/skills/permission_vocabulary.ts b/src/core/skills/permission_vocabulary.ts new file mode 100644 index 0000000..4927853 --- /dev/null +++ b/src/core/skills/permission_vocabulary.ts @@ -0,0 +1,67 @@ +// Canonical closed permission vocabulary for Agent Skills and the capability +// contract. One list, used by skill validation, the runtime policy gate, the +// capability manifest, and doctor. Adding a name here is a contract change. + +import type { ToolName } from "../brain_protocol.js"; + +export const PERMISSIONS = [ + "workspace.read", + "workspace.write", + "workspace.outside", + "shell.test", + "shell.execute", + "git.read", + "git.stage", + "git.commit", + "git.push", + "network.github.read", + "network.general", + "network.loopback", + "secrets.read", + "billing.spend", + "artifact.publish", +] as const; + +export type PermissionName = (typeof PERMISSIONS)[number]; + +const PERMISSION_SET: ReadonlySet = new Set(PERMISSIONS); + +export function isPermissionName(value: string): value is PermissionName { + return PERMISSION_SET.has(value); +} + +/** + * The one permission each canonical tool requires. A tool absent from a + * skill's allowed list is refused before this map is even consulted; this map + * decides which operator permission must ALSO be live for the call to run. + * Every ToolName must appear — validateToolPermissionCoverage() enforces it. + */ +export const TOOL_PERMISSIONS: Readonly> = { + read_file: "workspace.read", + repo_search: "workspace.read", + write_file: "workspace.write", + run_shell: "shell.execute", + run_tests: "shell.test", + git_commit: "git.commit", + web_search: "network.general", + web_fetch: "network.general", +}; + +/** + * Permissions a skill may never obtain through declaration alone. These need + * an explicit operator grant outside any skill; a manifest listing one under + * `requires` or `may_request` is schema-invalid, not merely denied at runtime. + */ +export const SKILL_UNDECLARABLE_PERMISSIONS: readonly PermissionName[] = [ + "workspace.outside", + "secrets.read", + "billing.spend", +]; + +export function validateToolPermissionCoverage(tools: readonly string[]): string[] { + const mapped = Object.keys(TOOL_PERMISSIONS).sort(); + const canonical = [...tools].sort(); + return mapped.length === canonical.length && mapped.every((name, index) => name === canonical[index]) + ? [] + : ["tool permission map does not exactly cover the frozen protocol tool set"]; +} diff --git a/src/core/skills/skill_bounds.ts b/src/core/skills/skill_bounds.ts new file mode 100644 index 0000000..84b6704 --- /dev/null +++ b/src/core/skills/skill_bounds.ts @@ -0,0 +1,37 @@ +// Central bounds for skill metadata, loading, and instruction context. +// Exposed through the capability manifest — change deliberately. + +export const SKILL_BOUNDS = { + /** skill.json on disk. */ + maxMetadataBytes: 64 * 1024, + /** SKILL.md instruction body. */ + maxInstructionBytes: 128 * 1024, + /** One declared resource file. */ + maxResourceBytes: 256 * 1024, + /** All loaded resources of one skill combined. */ + maxAggregateResourceBytes: 512 * 1024, + /** Skills loaded into one turn (explicit + automatic). */ + maxSkillsPerTurn: 6, + /** Automatic candidates loaded without explicit invocation. */ + maxAutomaticSkillsPerTurn: 3, + /** Skill dependency chain depth. */ + maxDependencyDepth: 3, + /** Approximate token budget for all loaded skill context. */ + maxLoadedSkillTokens: 16000, + /** Instruction sources merged into one turn. */ + maxInstructionSources: 12, + /** Nested AGENTS.md depth below the project root. */ + maxNestedInstructionDepth: 6, + /** One instruction file. */ + maxInstructionFileBytes: 64 * 1024, + /** Description / name / trigger phrase field lengths. */ + maxNameChars: 80, + maxDescriptionChars: 1024, + maxTriggerPhrases: 16, + maxTriggerPhraseChars: 120, + maxCommandAliases: 4, + maxResources: 32, + maxDependencies: 8, +} as const; + +export type SkillBounds = typeof SKILL_BOUNDS; diff --git a/src/core/skills/skill_digest.ts b/src/core/skills/skill_digest.ts new file mode 100644 index 0000000..beaf422 --- /dev/null +++ b/src/core/skills/skill_digest.ts @@ -0,0 +1,124 @@ +// Canonical content digest for a skill package. +// +// One SHA-256 over: normalized skill.json, SKILL.md (entrypoint), every +// DECLARED resource/eval file — deterministically path-sorted, length-prefixed +// so file boundaries cannot be forged by concatenation. Timestamps, filesystem +// metadata, and undeclared files never contribute. Trust binds to this digest. + +import { createHash } from "node:crypto"; +import { openSync, readSync, fstatSync, closeSync, constants } from "node:fs"; +import { join, resolve, sep } from "node:path"; +import { realpathSync } from "node:fs"; +import type { SkillManifest } from "./skill_schema.js"; +import { SKILL_BOUNDS } from "./skill_bounds.js"; + +export interface SkillDigestResult { + ok: true; + sha256: string; + /** Relative paths that contributed, sorted. */ + files: readonly string[]; +} + +export interface SkillDigestFailure { + ok: false; + error: string; +} + +/** JSON with sorted keys at every level — a stable byte form of the manifest. */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return "[" + value.map(canonicalJson).join(",") + "]"; + const record = value as Record; + const keys = Object.keys(record).sort(); + return "{" + keys.map((key) => JSON.stringify(key) + ":" + canonicalJson(record[key])).join(",") + "}"; +} + +/** + * Open one declared file with symlink-escape protection and a byte cap. + * Uses open + fstat on the handle (not path-based stat) so the content that is + * hashed is the content that was size-checked — no TOCTOU window between them. + */ +function readBoundedInsideRoot(root: string, relative: string, maxBytes: number): + | { ok: true; bytes: Buffer } + | { ok: false; error: string } { + const absolute = resolve(root, relative); + const realRoot = realpathSync(root); + let real: string; + try { + real = realpathSync(absolute); + } catch { + return { ok: false, error: "declared file missing: " + relative }; + } + if (real !== realRoot && !real.startsWith(realRoot + sep)) { + return { ok: false, error: "declared file escapes the skill root: " + relative }; + } + let fd: number; + try { + fd = openSync(real, constants.O_RDONLY); + } catch { + return { ok: false, error: "declared file unreadable: " + relative }; + } + try { + const stat = fstatSync(fd); + if (!stat.isFile()) return { ok: false, error: "declared path is not a regular file: " + relative }; + if (stat.size > maxBytes) { + return { ok: false, error: "declared file exceeds " + maxBytes + " bytes: " + relative }; + } + const bytes = Buffer.alloc(Number(stat.size)); + let offset = 0; + while (offset < bytes.length) { + const read = readSync(fd, bytes, offset, bytes.length - offset, offset); + if (read <= 0) break; + offset += read; + } + if (offset !== bytes.length) return { ok: false, error: "short read on declared file: " + relative }; + return { ok: true, bytes }; + } finally { + closeSync(fd); + } +} + +/** Every file the digest covers, relative to the skill root, sorted, deduplicated. */ +export function digestFileList(manifest: SkillManifest): string[] { + const files = new Set([manifest.entrypoint]); + for (const resource of manifest.context.resources) files.add(resource); + if (manifest.health.evalManifest) files.add(manifest.health.evalManifest); + return [...files].sort(); +} + +export function calculateSkillDigest( + root: string, + manifest: SkillManifest, + rawManifestValue: unknown, +): SkillDigestResult | SkillDigestFailure { + const hash = createHash("sha256"); + const manifestBytes = Buffer.from(canonicalJson(rawManifestValue), "utf8"); + hash.update("manifest\0"); + hash.update(String(manifestBytes.length) + "\0"); + hash.update(manifestBytes); + + const files = digestFileList(manifest); + for (const relative of files) { + const cap = relative === manifest.entrypoint + ? SKILL_BOUNDS.maxInstructionBytes + : SKILL_BOUNDS.maxResourceBytes; + const read = readBoundedInsideRoot(root, relative, cap); + if (!read.ok) return read; + hash.update("file\0" + relative + "\0"); + hash.update(String(read.bytes.length) + "\0"); + hash.update(read.bytes); + } + return { ok: true, sha256: hash.digest("hex"), files }; +} + +/** Digest of one in-memory buffer — used for per-resource digests in the context packet. */ +export function sha256Hex(bytes: Buffer | string): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export { readBoundedInsideRoot }; + +/** Convenience for building a path inside a skill root without traversal risk. */ +export function skillFilePath(root: string, relative: string): string { + return join(root, relative); +} diff --git a/src/core/skills/skill_discovery.ts b/src/core/skills/skill_discovery.ts new file mode 100644 index 0000000..c57a260 --- /dev/null +++ b/src/core/skills/skill_discovery.ts @@ -0,0 +1,183 @@ +// Skill discovery: metadata-only indexing of built-in, user, and project skills. +// +// Discovery reads bounded metadata (skill.json) plus the files the digest +// covers — it NEVER executes anything, follows symlinks out of a skill root, +// fetches remote resources, sends content to a model, or mutates trust state. +// SKILL.md bodies are hashed for the digest but the text is not retained here; +// loading happens later, only for a resolved candidate (skill_loader.ts). + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { configDir } from "../config.js"; +import { SKILL_BOUNDS } from "./skill_bounds.js"; +import { calculateSkillDigest } from "./skill_digest.js"; +import { validateSkillManifest, type SkillScope } from "./skill_schema.js"; +import { loadSkillSettings, lookupSkillSetting } from "./skill_settings.js"; +import { loadTrustStore, lookupTrust } from "./skill_trust.js"; +import type { SkillDescriptor, SkillIndex, SkillIndexError, SkillTrustState } from "./skill_types.js"; + +export interface DiscoveryOptions { + /** Project root (cwd) — project skills live under /.aether/skills/project/. */ + projectRoot: string; + /** Built-in skill resource root; defaults to the packaged builtin directory. */ + builtinRoot?: string; + /** Injected for tests. */ + now?: () => Date; +} + +export function builtinSkillsRoot(): string { + // dist/src/core/skills/ → dist/src/skills/builtin/ (compiled tree; assets + // are copied next to the compiled output by the build, verified by tests). + const here = dirname(fileURLToPath(import.meta.url)); + return join(here, "..", "..", "skills", "builtin"); +} + +export function projectSkillsRoot(projectRoot: string): string { + return join(projectRoot, ".aether", "skills", "project"); +} + +export function userSkillsRoot(): string { + return join(configDir(), "skills", "user"); +} + +interface ScanTarget { + scope: SkillScope; + root: string; +} + +function listSkillDirectories(root: string): string[] { + if (!existsSync(root)) return []; + let entries: string[]; + try { + entries = readdirSync(root); + } catch { + return []; + } + const out: string[] = []; + for (const entry of entries.sort()) { + const full = join(root, entry); + try { + if (statSync(full).isDirectory()) out.push(full); + } catch { + // unreadable entry — skip, never throw during discovery + } + } + return out; +} + +function discoverOne( + skillRoot: string, + scope: SkillScope, + projectRoot: string, +): { descriptor?: SkillDescriptor; error?: SkillIndexError } { + const manifestPath = join(skillRoot, "skill.json"); + if (!existsSync(manifestPath)) { + return { error: { root: skillRoot, scope, errors: ["skill.json not found"] } }; + } + let stat; + try { + stat = statSync(manifestPath); + } catch { + return { error: { root: skillRoot, scope, errors: ["skill.json unreadable"] } }; + } + if (stat.size > SKILL_BOUNDS.maxMetadataBytes) { + return { error: { root: skillRoot, scope, errors: ["skill.json exceeds " + SKILL_BOUNDS.maxMetadataBytes + " bytes"] } }; + } + let raw: unknown; + try { + raw = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + return { error: { root: skillRoot, scope, errors: ["skill.json is not valid JSON"] } }; + } + const validation = validateSkillManifest(raw, scope); + if (!validation.ok) { + return { error: { root: skillRoot, scope, errors: validation.errors } }; + } + const manifest = validation.manifest; + const digest = calculateSkillDigest(skillRoot, manifest, raw); + if (!digest.ok) { + return { error: { root: skillRoot, scope, errors: [digest.error] } }; + } + + const trustKey = scope === "project" ? resolve(projectRoot) : "*"; + let trust: SkillTrustState; + if (scope === "builtin") { + trust = "builtin"; + } else if (scope === "user") { + // User skills were created or explicitly installed by the user; trust + // binds to the digest recorded at install/creation. Absent a record the + // skill still runs (it is the user's own file) — recorded as trusted. + trust = "trusted"; + } else { + const lookup = lookupTrust(loadTrustStore(), trustKey, manifest.id, digest.sha256); + trust = lookup.state; + } + + const settings = loadSkillSettings(); + const setting = lookupSkillSetting(settings, trustKey, manifest.id); + const enabled = setting ? setting.enabled : true; + // Automatic selection: built-ins follow the manifest; user/project skills + // need an explicit local opt-in, and project skills additionally need trust. + let automatic = false; + if (manifest.triggers.automatic) { + if (scope === "builtin") automatic = true; + else if (scope === "user") automatic = setting?.automatic === true; + else automatic = setting?.automatic === true && trust === "trusted"; + } + + return { + descriptor: { + id: manifest.id, + version: manifest.version, + name: manifest.name, + description: manifest.description, + scope, + root: skillRoot, + sha256: digest.sha256, + trust, + enabled, + automatic: automatic && enabled, + approxTokens: manifest.context.maxTokens, + manifest, + }, + }; +} + +/** + * Build the metadata-only index. Indexing order (builtin, user, project) is for + * listing only — name collisions are resolved explicitly by the resolver, + * never silently by source order. + */ +export function discoverSkills(options: DiscoveryOptions): SkillIndex { + const targets: ScanTarget[] = [ + { scope: "builtin", root: options.builtinRoot ?? builtinSkillsRoot() }, + { scope: "user", root: userSkillsRoot() }, + { scope: "project", root: projectSkillsRoot(options.projectRoot) }, + ]; + const skills: SkillDescriptor[] = []; + const errors: SkillIndexError[] = []; + const seenIds = new Set(); + + for (const target of targets) { + for (const skillRoot of listSkillDirectories(target.root)) { + const result = discoverOne(skillRoot, target.scope, options.projectRoot); + if (result.error) { errors.push(result.error); continue; } + const descriptor = result.descriptor; + if (!descriptor) continue; + if (seenIds.has(descriptor.id)) { + errors.push({ + root: skillRoot, + scope: target.scope, + errors: ["duplicate skill id: " + descriptor.id + " — fully qualified ids must be unique"], + }); + continue; + } + seenIds.add(descriptor.id); + skills.push(descriptor); + } + } + + const now = options.now ? options.now() : new Date(); + return { skills, errors, generatedAt: now.toISOString() }; +} diff --git a/src/core/skills/skill_errors.ts b/src/core/skills/skill_errors.ts new file mode 100644 index 0000000..0c2b6fb --- /dev/null +++ b/src/core/skills/skill_errors.ts @@ -0,0 +1,45 @@ +// Stable machine codes for every skill refusal. Rendered human guidance lives +// in the UI layer — these codes are the wire/API contract and must never be +// renamed once released (CONTRACTS.md discipline applies). + +export const SKILL_ERROR_CODES = [ + "skill.untrusted", + "skill.changed", + "skill.disabled", + "skill.ambiguous", + "skill.not_found", + "skill.schema_invalid", + "skill.version_incompatible", + "skill.dependency_missing", + "skill.dependency_cycle", + "skill.context_budget_exceeded", + "skill.tool_not_declared", + "skill.permission_unavailable", + "skill.permission_denied", + "skill.resource_unsafe", + "skill.resource_changed", + "skill.server_unsupported", +] as const; + +export type SkillErrorCode = (typeof SKILL_ERROR_CODES)[number]; + +/** Structured refusal returned instead of executing. Never execute-then-explain. */ +export interface SkillRefusal { + code: SkillErrorCode; + skillId?: string; + detail: string; + /** Extra machine-readable context, e.g. effective_allowed_tools on tool_not_declared. */ + context?: Readonly>; +} + +export class SkillError extends Error { + readonly code: SkillErrorCode; + readonly refusal: SkillRefusal; + + constructor(refusal: SkillRefusal) { + super(refusal.code + ": " + refusal.detail); + this.name = "SkillError"; + this.code = refusal.code; + this.refusal = refusal; + } +} diff --git a/src/core/skills/skill_eval.ts b/src/core/skills/skill_eval.ts new file mode 100644 index 0000000..b1233da --- /dev/null +++ b/src/core/skills/skill_eval.ts @@ -0,0 +1,203 @@ +// Deterministic, offline skill evaluation. +// +// Layers (spec: schema → resolution → policy), all pure over the index and a +// fixture file — no model call, no network, no UVT. Live provider evaluation +// is a separate, explicitly authorized path that does not exist here. + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { calculateSkillPolicy, defaultPermissionEnvelope, refuseUndeclaredToolCall } from "./skill_policy.js"; +import { loadSkillBody } from "./skill_loader.js"; +import { resolveAutomatic, resolveExplicit } from "./skill_resolver.js"; +import { SkillError } from "./skill_errors.js"; +import type { SkillDescriptor, SkillIndex } from "./skill_types.js"; + +export interface SkillEvalCase { + id: string; + input: string; + expected: { + selectedSkill?: string; + allowedTools?: readonly string[]; + forbiddenTools?: readonly string[]; + requiredOutputKind?: string; + maxUvt?: number; + }; +} + +export interface SkillEvalOutcome { + caseId: string; + status: "pass" | "fail"; + detail: string; +} + +export interface SkillEvalReport { + skillId: string; + cases: readonly SkillEvalOutcome[]; + pass: number; + fail: number; +} + +export type EvalManifestValidation = + | { ok: true; cases: readonly SkillEvalCase[] } + | { ok: false; errors: readonly string[] }; + +/** Parse + shape-check one evals/cases.json manifest. Strict, actionable errors. */ +export function parseEvalManifest(raw: unknown): EvalManifestValidation { + const errors: string[] = []; + if (!Array.isArray(raw)) return { ok: false, errors: ["eval manifest must be a JSON array of cases"] }; + const cases: SkillEvalCase[] = []; + const seen = new Set(); + for (const [index, entry] of raw.entries()) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + errors.push("case " + index + " must be an object"); + continue; + } + const item = entry as Record; + const id = item["id"]; + const input = item["input"]; + const expected = item["expected"]; + if (typeof id !== "string" || !id) { errors.push("case " + index + " needs a string id"); continue; } + if (seen.has(id)) { errors.push("duplicate case id: " + id); continue; } + seen.add(id); + if (typeof input !== "string" || !input) { errors.push("case " + id + " needs a string input"); continue; } + if (typeof expected !== "object" || expected === null) { errors.push("case " + id + " needs an expected object"); continue; } + const expectedRecord = expected as Record; + const strList = (key: string): string[] | undefined => { + const value = expectedRecord[key]; + if (value == null) return undefined; + if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string")) { + errors.push("case " + id + " expected." + key + " must be a string array"); + return undefined; + } + return value as string[]; + }; + const selected = expectedRecord["selected_skill"]; + const outputKind = expectedRecord["required_output_kind"]; + const maxUvt = expectedRecord["max_uvt"]; + const parsed: SkillEvalCase = { + id, + input, + expected: { + ...(typeof selected === "string" ? { selectedSkill: selected } : {}), + ...(strList("allowed_tools") ? { allowedTools: strList("allowed_tools") } : {}), + ...(strList("forbidden_tools") ? { forbiddenTools: strList("forbidden_tools") } : {}), + ...(typeof outputKind === "string" ? { requiredOutputKind: outputKind } : {}), + ...(typeof maxUvt === "number" ? { maxUvt } : {}), + }, + }; + cases.push(parsed); + } + if (errors.length) return { ok: false, errors }; + return { ok: true, cases }; +} + +export function loadEvalManifest(descriptor: SkillDescriptor): EvalManifestValidation { + const manifestPath = descriptor.manifest.health.evalManifest; + if (!manifestPath) return { ok: false, errors: ["skill declares no eval manifest"] }; + const full = join(descriptor.root, manifestPath); + if (!existsSync(full)) return { ok: false, errors: ["eval manifest missing: " + manifestPath] }; + try { + return parseEvalManifest(JSON.parse(readFileSync(full, "utf8"))); + } catch { + return { ok: false, errors: ["eval manifest is not valid JSON: " + manifestPath] }; + } +} + +/** + * Run one skill's offline eval suite against the live index. + * max_uvt expectations: this runner NEVER spends, so any expected.maxUvt > 0 + * is a manifest error — offline evals prove zero-spend by construction. + */ +export function runSkillEvals(index: SkillIndex, descriptor: SkillDescriptor): SkillEvalReport { + const manifest = loadEvalManifest(descriptor); + if (!manifest.ok) { + return { + skillId: descriptor.id, + cases: manifest.errors.map((error) => ({ caseId: "manifest", status: "fail" as const, detail: error })), + pass: 0, + fail: manifest.errors.length, + }; + } + const outcomes: SkillEvalOutcome[] = []; + for (const evalCase of manifest.cases) { + outcomes.push(runOneCase(index, descriptor, evalCase)); + } + const pass = outcomes.filter((outcome) => outcome.status === "pass").length; + return { skillId: descriptor.id, cases: outcomes, pass, fail: outcomes.length - pass }; +} + +function runOneCase(index: SkillIndex, descriptor: SkillDescriptor, evalCase: SkillEvalCase): SkillEvalOutcome { + const failures: string[] = []; + const expected = evalCase.expected; + + if (expected.maxUvt != null && expected.maxUvt > 0) { + failures.push("offline evals are zero-spend; expected.max_uvt must be 0"); + } + + // Resolution layer. + if (expected.selectedSkill) { + let selectedId: string | null = null; + const automatic = resolveAutomatic(index, evalCase.input); + if (automatic.length && automatic[0]) { + selectedId = automatic[0].candidate.descriptor.id; + } else { + try { + selectedId = resolveExplicit(index, descriptor.id).candidate.descriptor.id; + } catch (err) { + failures.push(err instanceof SkillError ? err.refusal.code : String(err)); + } + } + if (selectedId && selectedId !== expected.selectedSkill) { + failures.push("selected " + selectedId + ", expected " + expected.selectedSkill); + } + } + + // Policy layer — load lazily, then verify tool intersection both ways. + try { + const loaded = loadSkillBody(descriptor, "explicit"); + const policy = calculateSkillPolicy(loaded); + const envelope = defaultPermissionEnvelope(); + for (const tool of expected.allowedTools ?? []) { + const refusal = refuseUndeclaredToolCall(tool, [policy], envelope); + if (refusal) failures.push("expected allowed tool refused: " + tool + " (" + refusal.code + ")"); + } + for (const tool of expected.forbiddenTools ?? []) { + const refusal = refuseUndeclaredToolCall(tool, [policy], envelope); + if (!refusal) failures.push("expected forbidden tool was allowed: " + tool); + } + if (expected.requiredOutputKind && !descriptor.manifest.outputs.kinds.includes(expected.requiredOutputKind)) { + failures.push("manifest outputs.kinds lacks " + expected.requiredOutputKind); + } + } catch (err) { + failures.push(err instanceof SkillError ? err.refusal.code + ": " + err.refusal.detail : String(err)); + } + + return failures.length + ? { caseId: evalCase.id, status: "fail", detail: failures.join("; ") } + : { caseId: evalCase.id, status: "pass", detail: "resolution + policy verified" }; +} + +/** JUnit XML for CI consumers (`aether skills eval --all --junit `). */ +export function renderEvalJUnit(reports: readonly SkillEvalReport[]): string { + const escape = (text: string): string => + text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const total = reports.reduce((sum, report) => sum + report.cases.length, 0); + const failures = reports.reduce((sum, report) => sum + report.fail, 0); + const lines = ['']; + lines.push(''); + for (const report of reports) { + lines.push(' '); + for (const outcome of report.cases) { + if (outcome.status === "pass") { + lines.push(' '); + } else { + lines.push(' '); + lines.push(' '); + lines.push(" "); + } + } + lines.push(" "); + } + lines.push(""); + return lines.join("\n") + "\n"; +} diff --git a/src/core/skills/skill_loader.ts b/src/core/skills/skill_loader.ts new file mode 100644 index 0000000..ee202d5 --- /dev/null +++ b/src/core/skills/skill_loader.ts @@ -0,0 +1,86 @@ +// Lazy body loading for a RESOLVED skill. Loads the entrypoint plus only the +// declared resources, re-verifying the content digest at open time so a file +// swapped between indexing and loading (TOCTOU) is refused, not shipped. + +import { SkillError } from "./skill_errors.js"; +import { SKILL_BOUNDS } from "./skill_bounds.js"; +import { readBoundedInsideRoot, sha256Hex, calculateSkillDigest } from "./skill_digest.js"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { LoadedSkill, LoadedSkillResource, SkillDescriptor, SkillInvocationKind } from "./skill_types.js"; + +/** + * Load the instruction body and declared resources of one skill. + * The whole-package digest is recalculated first and must equal the digest the + * descriptor was indexed (and trusted) under — any drift refuses with + * skill.resource_changed before a byte of content is returned. + */ +export function loadSkillBody( + descriptor: SkillDescriptor, + invocation: SkillInvocationKind, +): LoadedSkill { + const manifestPath = join(descriptor.root, "skill.json"); + if (!existsSync(manifestPath)) { + throw new SkillError({ code: "skill.resource_changed", skillId: descriptor.id, detail: "skill.json disappeared since indexing" }); + } + let raw: unknown; + try { + raw = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + throw new SkillError({ code: "skill.resource_changed", skillId: descriptor.id, detail: "skill.json became unreadable since indexing" }); + } + const recheck = calculateSkillDigest(descriptor.root, descriptor.manifest, raw); + if (!recheck.ok) { + throw new SkillError({ code: "skill.resource_unsafe", skillId: descriptor.id, detail: recheck.error }); + } + if (recheck.sha256 !== descriptor.sha256) { + throw new SkillError({ + code: "skill.resource_changed", + skillId: descriptor.id, + detail: "content changed between indexing and load — re-index and re-trust before invoking", + }); + } + + const entry = readBoundedInsideRoot(descriptor.root, descriptor.manifest.entrypoint, SKILL_BOUNDS.maxInstructionBytes); + if (!entry.ok) { + throw new SkillError({ code: "skill.resource_unsafe", skillId: descriptor.id, detail: entry.error }); + } + const instructions = entry.bytes.toString("utf8"); + + const resources: LoadedSkillResource[] = []; + let aggregate = 0; + for (const resourcePath of descriptor.manifest.context.resources) { + const read = readBoundedInsideRoot(descriptor.root, resourcePath, SKILL_BOUNDS.maxResourceBytes); + if (!read.ok) { + throw new SkillError({ code: "skill.resource_unsafe", skillId: descriptor.id, detail: read.error }); + } + if (read.bytes.includes(0)) { + throw new SkillError({ + code: "skill.resource_unsafe", + skillId: descriptor.id, + detail: "binary resource not supported: " + resourcePath, + }); + } + aggregate += read.bytes.length; + if (aggregate > SKILL_BOUNDS.maxAggregateResourceBytes) { + throw new SkillError({ + code: "skill.context_budget_exceeded", + skillId: descriptor.id, + detail: "declared resources exceed " + SKILL_BOUNDS.maxAggregateResourceBytes + " aggregate bytes", + }); + } + resources.push({ + name: resourcePath, + sha256: sha256Hex(read.bytes), + content: read.bytes.toString("utf8"), + }); + } + + return { + descriptor, + invocation, + instructions, + resources, + loadedBytes: entry.bytes.length + aggregate, + }; +} diff --git a/src/core/skills/skill_lock.ts b/src/core/skills/skill_lock.ts new file mode 100644 index 0000000..0860bb8 --- /dev/null +++ b/src/core/skills/skill_lock.ts @@ -0,0 +1,120 @@ +// skills.lock.json — records the digests of skills at lock time. +// +// A lock is safe to commit: it contains digests and paths only, never a trust +// decision or a secret. Trust lives in the local trust store (skill_trust.ts). +// Writes are atomic (write-tmp-then-rename, same rationale as config.ts). + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { configDir } from "../config.js"; + +export const SKILL_LOCK_SCHEMA_VERSION = 1; + +export interface SkillLockEntry { + id: string; + version: string; + /** Relative source path for project locks; absolute allowed for user locks. */ + source: string; + sha256: string; + dependencies: readonly string[]; +} + +export interface SkillLock { + schemaVersion: number; + skills: readonly SkillLockEntry[]; +} + +export function projectLockPath(projectRoot: string): string { + return join(projectRoot, ".aether", "skills.lock.json"); +} + +export function userLockPath(): string { + return join(configDir(), "skills.lock.json"); +} + +export type LockReadResult = + | { ok: true; lock: SkillLock } + | { ok: false; missing: boolean; error: string }; + +export function readSkillLock(path: string): LockReadResult { + if (!existsSync(path)) return { ok: false, missing: true, error: "lock file not found: " + path }; + let raw: unknown; + try { + raw = JSON.parse(readFileSync(path, "utf8")); + } catch { + return { ok: false, missing: false, error: "lock file is not valid JSON: " + path }; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { ok: false, missing: false, error: "lock file must be a JSON object" }; + } + const record = raw as Record; + if (record["schema_version"] !== SKILL_LOCK_SCHEMA_VERSION) { + return { ok: false, missing: false, error: "unsupported lock schema_version: " + String(record["schema_version"]) }; + } + const skillsRaw = record["skills"]; + if (!Array.isArray(skillsRaw)) return { ok: false, missing: false, error: "lock skills must be an array" }; + const skills: SkillLockEntry[] = []; + for (const entry of skillsRaw) { + if (typeof entry !== "object" || entry === null) return { ok: false, missing: false, error: "lock entry must be an object" }; + const item = entry as Record; + const id = item["id"]; + const version = item["version"]; + const source = item["source"]; + const sha256 = item["sha256"]; + const dependencies = item["dependencies"]; + if (typeof id !== "string" || typeof version !== "string" || typeof source !== "string" || typeof sha256 !== "string") { + return { ok: false, missing: false, error: "lock entry missing id/version/source/sha256" }; + } + if (!/^[0-9a-f]{64}$/.test(sha256)) { + return { ok: false, missing: false, error: "lock entry sha256 must be 64 hex chars: " + id }; + } + skills.push({ + id, version, source, sha256, + dependencies: Array.isArray(dependencies) ? dependencies.filter((d): d is string => typeof d === "string") : [], + }); + } + return { ok: true, lock: { schemaVersion: SKILL_LOCK_SCHEMA_VERSION, skills } }; +} + +export function writeSkillLock(path: string, entries: readonly SkillLockEntry[]): void { + mkdirSync(dirname(path), { recursive: true }); + const sorted = [...entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const body = { + schema_version: SKILL_LOCK_SCHEMA_VERSION, + skills: sorted.map((entry) => ({ + id: entry.id, + version: entry.version, + source: entry.source, + sha256: entry.sha256, + dependencies: [...entry.dependencies].sort(), + })), + }; + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +export interface LockDrift { + /** In the index but absent from the lock. */ + unlocked: readonly string[]; + /** In the lock but no longer discovered. */ + missing: readonly string[]; + /** Present in both but digest differs. */ + changed: readonly string[]; +} + +export function compareLock( + lock: SkillLock, + discovered: ReadonlyMap, +): LockDrift { + const locked = new Map(lock.skills.map((entry) => [entry.id, entry.sha256])); + const unlocked: string[] = []; + const changed: string[] = []; + for (const [id, sha256] of discovered) { + const lockedSha = locked.get(id); + if (lockedSha == null) unlocked.push(id); + else if (lockedSha !== sha256) changed.push(id); + } + const missing = [...locked.keys()].filter((id) => !discovered.has(id)); + return { unlocked: unlocked.sort(), missing: missing.sort(), changed: changed.sort() }; +} diff --git a/src/core/skills/skill_policy.ts b/src/core/skills/skill_policy.ts new file mode 100644 index 0000000..7499948 --- /dev/null +++ b/src/core/skills/skill_policy.ts @@ -0,0 +1,129 @@ +// Effective policy calculation and per-call enforcement. +// +// The effective authority is an INTERSECTION — a skill can only narrow, never +// widen. Enforced in the host immediately before tool execution; a request for +// an undeclared tool returns a structured refusal, never execute-then-explain. + +import { TOOLS, type ToolName } from "../brain_protocol.js"; +import { SkillError, type SkillRefusal } from "./skill_errors.js"; +import { TOOL_PERMISSIONS, type PermissionName } from "./permission_vocabulary.js"; +import type { LoadedSkill, SkillPolicy } from "./skill_types.js"; + +/** + * Permissions live in the current operator session, derived from mode/flags — + * calculated by the caller (host loop) from PermissionMode + autoApply + any + * explicit grants. Skills never contribute to this set. + */ +export type PermissionEnvelope = ReadonlySet; + +/** + * The default operator envelope: everything an interactive session can grant + * through the ask/auto/skip gate. Excludes the permissions no skill may + * declare and no ordinary session holds (workspace.outside, secrets.read, + * billing.spend) — those need explicit advanced authority outside this path. + */ +export function defaultPermissionEnvelope(): PermissionEnvelope { + return new Set([ + "workspace.read", + "workspace.write", + "shell.test", + "shell.execute", + "git.read", + "git.stage", + "git.commit", + "git.push", + "network.github.read", + "network.general", + "network.loopback", + "artifact.publish", + ]); +} + +/** + * The skill's own narrowing of the tool surface. Operator-envelope checks + * happen per call in refuseUndeclaredToolCall — this stays envelope-free so a + * cached policy can never go stale against a mode change mid-session. + */ +export function calculateSkillPolicy(skill: LoadedSkill): SkillPolicy { + const manifest = skill.descriptor.manifest; + const allowed: string[] = []; + for (const tool of manifest.tools.allowed) { + const needed = TOOL_PERMISSIONS[tool as ToolName]; + // A tool whose permission is forbidden by the skill itself is never + // effective, regardless of the operator envelope. + if (needed && manifest.permissions.forbids.includes(needed)) continue; + allowed.push(tool); + } + return { + skillId: skill.descriptor.id, + allowedTools: allowed, + requiredPermissions: manifest.permissions.requires, + forbiddenPermissions: manifest.permissions.forbids, + }; +} + +/** + * Refuse when a required permission is missing from the operator envelope. + * Called once at invocation time — a skill that cannot get what it REQUIRES + * does not run at all (may_request permissions degrade gracefully instead). + */ +export function assertRequiredPermissions( + policy: SkillPolicy, + envelope: PermissionEnvelope, +): void { + for (const permission of policy.requiredPermissions) { + if (!envelope.has(permission)) { + throw new SkillError({ + code: "skill.permission_unavailable", + skillId: policy.skillId, + detail: "required permission not in the active envelope: " + permission, + context: { permission }, + }); + } + } +} + +/** + * Per-tool-call gate. Returns null when the call may proceed, otherwise the + * structured refusal to send back to the brain. Checks, in order: + * 1. tool is a known canonical tool + * 2. tool is declared allowed by every active skill policy + * 3. the tool's permission is not forbidden by any active skill + * 4. the tool's permission is present in the operator envelope + */ +export function refuseUndeclaredToolCall( + tool: string, + policies: readonly SkillPolicy[], + envelope: PermissionEnvelope, +): SkillRefusal | null { + if (!(TOOLS as readonly string[]).includes(tool)) { + return { code: "skill.tool_not_declared", detail: "unknown tool: " + tool }; + } + const needed = TOOL_PERMISSIONS[tool as ToolName]; + for (const policy of policies) { + if (!policy.allowedTools.includes(tool)) { + return { + code: "skill.tool_not_declared", + skillId: policy.skillId, + detail: "tool '" + tool + "' is not declared by " + policy.skillId, + context: { tool, effective_allowed_tools: policy.allowedTools }, + }; + } + if (policy.forbiddenPermissions.includes(needed)) { + return { + code: "skill.permission_denied", + skillId: policy.skillId, + detail: "permission '" + needed + "' is forbidden by " + policy.skillId, + context: { tool, permission: needed }, + }; + } + } + if (policies.length > 0 && !envelope.has(needed)) { + return { + code: "skill.permission_unavailable", + detail: "permission '" + needed + "' is not in the active envelope", + context: { tool, permission: needed }, + }; + } + return null; +} diff --git a/src/core/skills/skill_resolver.ts b/src/core/skills/skill_resolver.ts new file mode 100644 index 0000000..e1de885 --- /dev/null +++ b/src/core/skills/skill_resolver.ts @@ -0,0 +1,164 @@ +// Explicit and automatic skill resolution over the metadata index. +// Pure functions — no filesystem, no model, no mutation. +// +// Explicit order: exact fully qualified id → exact unique short name → +// exact unique declared command alias → ambiguity error. No source-order +// preference ever breaks a tie silently. + +import { SkillError } from "./skill_errors.js"; +import { SKILL_BOUNDS } from "./skill_bounds.js"; +import type { SkillCandidate, SkillDescriptor, SkillIndex, ResolvedSkill } from "./skill_types.js"; + +function shortName(id: string): string { + return id.split("/")[1] ?? id; +} + +function describeMatches(matches: readonly SkillDescriptor[]): string[] { + return matches.map((descriptor) => descriptor.id + "@" + descriptor.version); +} + +/** + * Resolve one explicit reference. Trust/enable checks happen here so a caller + * cannot accidentally load a resolved-but-untrusted skill: resolution of a + * disabled or untrusted skill THROWS the structured refusal. + */ +export function resolveExplicit(index: SkillIndex, reference: string): ResolvedSkill { + const query = reference.trim().toLowerCase(); + if (!query) throw new SkillError({ code: "skill.not_found", detail: "empty skill reference" }); + + let matches: SkillDescriptor[]; + if (query.includes("/")) { + matches = index.skills.filter((descriptor) => descriptor.id === query); + } else { + matches = index.skills.filter((descriptor) => shortName(descriptor.id) === query); + if (matches.length === 0) { + matches = index.skills.filter((descriptor) => descriptor.manifest.triggers.commands.includes(query)); + } + } + + if (matches.length === 0) { + throw new SkillError({ code: "skill.not_found", skillId: reference, detail: "no skill matches '" + reference + "'" }); + } + if (matches.length > 1) { + throw new SkillError({ + code: "skill.ambiguous", + skillId: reference, + detail: "'" + reference + "' matches more than one skill — use the fully qualified id", + context: { matches: describeMatches(matches) }, + }); + } + + const descriptor = matches[0]; + if (!descriptor) throw new SkillError({ code: "skill.not_found", skillId: reference, detail: "no skill matches" }); + assertInvokable(descriptor); + + const candidate: SkillCandidate = { + descriptor, + invocation: "explicit", + reason: query.includes("/") ? "exact id" : "unique name '" + query + "'", + confidence: 1, + }; + return { candidate, loadOrder: dependencyOrder(index, descriptor) }; +} + +export function assertInvokable(descriptor: SkillDescriptor): void { + if (!descriptor.enabled) { + throw new SkillError({ code: "skill.disabled", skillId: descriptor.id, detail: "skill is disabled — enable with: aether skills enable " + descriptor.id }); + } + if (descriptor.trust === "changed") { + throw new SkillError({ + code: "skill.changed", + skillId: descriptor.id, + detail: "skill content changed since it was trusted — inspect and re-trust: aether skills trust " + descriptor.id, + }); + } + if (descriptor.trust === "untrusted") { + throw new SkillError({ + code: "skill.untrusted", + skillId: descriptor.id, + detail: "project skill is untrusted — inspect and trust: aether skills trust " + descriptor.id, + }); + } +} + +/** + * Dependency-ordered load list (dependencies first, target last), with cycle + * and missing-dependency detection and a bounded depth. + */ +export function dependencyOrder(index: SkillIndex, target: SkillDescriptor): SkillDescriptor[] { + const byId = new Map(index.skills.map((descriptor) => [descriptor.id, descriptor])); + const order: SkillDescriptor[] = []; + const visiting = new Set(); + const done = new Set(); + + const visit = (descriptor: SkillDescriptor, depth: number): void => { + if (done.has(descriptor.id)) return; + if (visiting.has(descriptor.id)) { + throw new SkillError({ + code: "skill.dependency_cycle", + skillId: target.id, + detail: "dependency cycle through " + descriptor.id, + }); + } + if (depth > SKILL_BOUNDS.maxDependencyDepth) { + throw new SkillError({ + code: "skill.dependency_cycle", + skillId: target.id, + detail: "dependency depth exceeds " + SKILL_BOUNDS.maxDependencyDepth, + }); + } + visiting.add(descriptor.id); + for (const dependencyId of descriptor.manifest.dependencies.skills) { + const dependency = byId.get(dependencyId); + if (!dependency) { + throw new SkillError({ + code: "skill.dependency_missing", + skillId: target.id, + detail: "dependency not found: " + dependencyId, + }); + } + assertInvokable(dependency); + visit(dependency, depth + 1); + } + visiting.delete(descriptor.id); + done.add(descriptor.id); + order.push(descriptor); + }; + + visit(target, 0); + return order; +} + +export interface AutomaticMatch { + candidate: SkillCandidate; +} + +/** + * Bounded automatic selection over the metadata-only candidate catalog. + * Considers ONLY skills already marked automatic (built-ins with + * `automatic: true`, or user/project skills explicitly opted in — project ones + * only when trusted; discovery already folded those rules into `automatic`). + * Matching is deterministic phrase containment — no model call. + */ +export function resolveAutomatic(index: SkillIndex, prompt: string): AutomaticMatch[] { + const text = prompt.toLowerCase(); + const matches: AutomaticMatch[] = []; + for (const descriptor of index.skills) { + if (!descriptor.automatic || !descriptor.enabled) continue; + if (descriptor.trust === "untrusted" || descriptor.trust === "changed") continue; + const phrase = descriptor.manifest.triggers.phrases.find((candidatePhrase) => + text.includes(candidatePhrase.toLowerCase()), + ); + if (!phrase) continue; + matches.push({ + candidate: { + descriptor, + invocation: "automatic", + reason: "prompt contains trigger phrase '" + phrase + "'", + confidence: Math.min(1, phrase.length / 40), + }, + }); + if (matches.length >= SKILL_BOUNDS.maxAutomaticSkillsPerTurn) break; + } + return matches; +} diff --git a/src/core/skills/skill_schema.ts b/src/core/skills/skill_schema.ts new file mode 100644 index 0000000..20f16f1 --- /dev/null +++ b/src/core/skills/skill_schema.ts @@ -0,0 +1,368 @@ +// aether.skill/v1 — strict manifest schema for Agent Skills. +// +// Strict JSON only (zero-runtime-dependency repo: no YAML). Unknown keys, +// malformed fields, and unsafe paths are hard errors with actionable messages — +// nothing is silently discarded. Validation here is pure and filesystem-free; +// path EXISTENCE and symlink safety are checked later by the resource loader. + +import { TOOLS } from "../brain_protocol.js"; +import { + isPermissionName, + SKILL_UNDECLARABLE_PERMISSIONS, + type PermissionName, +} from "./permission_vocabulary.js"; +import { SKILL_BOUNDS } from "./skill_bounds.js"; + +export const SKILL_SCHEMA_VERSION = 1; + +/** `builtin/`, `user/`, `project/` — or reserved `aether/`. */ +export type SkillScope = "builtin" | "user" | "project"; + +export interface SkillTriggers { + commands: readonly string[]; + phrases: readonly string[]; + automatic: boolean; +} + +export interface SkillToolPolicy { + allowed: readonly string[]; + required: readonly string[]; + denied: readonly string[]; +} + +export interface SkillPermissionPolicy { + requires: readonly PermissionName[]; + mayRequest: readonly PermissionName[]; + forbids: readonly PermissionName[]; +} + +export interface SkillContextSpec { + maxTokens: number; + maxResources: number; + resources: readonly string[]; +} + +export interface SkillOutputSpec { + kinds: readonly string[]; + verification: readonly string[]; +} + +export interface SkillCompatibility { + minAgentVersion: string; + capabilityContract: number; +} + +export interface SkillManifest { + schemaVersion: number; + id: string; + version: string; + name: string; + description: string; + entrypoint: string; + triggers: SkillTriggers; + tools: SkillToolPolicy; + permissions: SkillPermissionPolicy; + context: SkillContextSpec; + outputs: SkillOutputSpec; + dependencies: { skills: readonly string[] }; + compatibility: SkillCompatibility; + health: { evalManifest: string | null }; +} + +export type ManifestValidation = + | { ok: true; manifest: SkillManifest } + | { ok: false; errors: readonly string[] }; + +const ID_PATTERN = /^(aether|builtin|user|project)\/[a-z0-9][a-z0-9-]{0,63}$/; +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const COMMAND_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/; + +const TOP_KEYS = new Set([ + "$schema", "schema_version", "id", "version", "name", "description", + "entrypoint", "triggers", "tools", "permissions", "context", "outputs", + "dependencies", "compatibility", "health", +]); + +const TOOL_SET: ReadonlySet = new Set(TOOLS); + +/** Relative, normalized, inside-root path — no `..`, no absolute, no URL, no backslash. */ +export function isSafeRelativePath(path: string): boolean { + if (!path || path.length > 512) return false; + if (path.includes("\\") || path.includes("\0")) return false; + if (path.startsWith("/") || /^[a-zA-Z]:/.test(path)) return false; + if (/^[a-z][a-z0-9+.-]*:/i.test(path)) return false; + const parts = path.split("/"); + return parts.every((part) => part.length > 0 && part !== "." && part !== ".."); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringList( + value: unknown, label: string, errors: string[], + maxItems: number, maxChars: number, +): string[] { + if (value == null) return []; + if (!Array.isArray(value)) { errors.push(label + " must be an array"); return []; } + if (value.length > maxItems) errors.push(label + " exceeds " + maxItems + " entries"); + const out: string[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== "string" || item.length === 0) { errors.push(label + " entries must be non-empty strings"); continue; } + if (item.length > maxChars) { errors.push(label + " entry exceeds " + maxChars + " chars: " + item.slice(0, 40)); continue; } + if (seen.has(item)) { errors.push(label + " has duplicate entry: " + item); continue; } + seen.add(item); + out.push(item); + } + return out; +} + +function unknownKeys(record: Record, allowed: readonly string[], label: string, errors: string[]): void { + const set = new Set(allowed); + for (const key of Object.keys(record)) { + if (!set.has(key)) errors.push(label + " has unknown key: " + key); + } +} + +/** + * Validate one parsed skill.json value against aether.skill/v1. + * `scope` is where the skill was FOUND — the id's declared scope must agree, + * and `aether/*` is reserved for signed built-ins. + */ +export function validateSkillManifest(raw: unknown, scope: SkillScope): ManifestValidation { + const errors: string[] = []; + if (!isRecord(raw)) return { ok: false, errors: ["skill.json must be a JSON object"] }; + + unknownKeys(raw, [...TOP_KEYS], "manifest", errors); + + const schemaVersion = raw["schema_version"]; + if (schemaVersion !== SKILL_SCHEMA_VERSION) { + return { + ok: false, + errors: [ + "unsupported schema_version " + String(schemaVersion) + + " — this agent supports aether.skill/v" + SKILL_SCHEMA_VERSION + + "; upgrade the agent or re-author the skill against the supported schema", + ], + }; + } + + const id = typeof raw["id"] === "string" ? raw["id"] : ""; + if (!ID_PATTERN.test(id)) { + errors.push("id must match / with scope aether|builtin|user|project, lowercase"); + } else { + const declaredScope = id.split("/")[0] ?? ""; + if (declaredScope === "aether" && scope !== "builtin") { + errors.push("the aether/* namespace is reserved for signed built-in skills"); + } else if (declaredScope !== "aether" && declaredScope !== scope) { + errors.push("id scope '" + declaredScope + "' does not match discovery scope '" + scope + "'"); + } + } + + const version = typeof raw["version"] === "string" ? raw["version"] : ""; + if (!SEMVER_PATTERN.test(version)) errors.push("version must be strict semver MAJOR.MINOR.PATCH"); + + const name = typeof raw["name"] === "string" ? raw["name"].trim() : ""; + if (!name || name.length > SKILL_BOUNDS.maxNameChars) { + errors.push("name is required, at most " + SKILL_BOUNDS.maxNameChars + " chars"); + } + const description = typeof raw["description"] === "string" ? raw["description"].trim() : ""; + if (!description || description.length > SKILL_BOUNDS.maxDescriptionChars) { + errors.push("description is required, at most " + SKILL_BOUNDS.maxDescriptionChars + " chars"); + } + + const entrypoint = typeof raw["entrypoint"] === "string" ? raw["entrypoint"] : "SKILL.md"; + if (!isSafeRelativePath(entrypoint)) errors.push("entrypoint must be a safe relative path inside the skill root"); + + // triggers + let triggers: SkillTriggers = { commands: [], phrases: [], automatic: false }; + const rawTriggers = raw["triggers"]; + if (rawTriggers != null) { + if (!isRecord(rawTriggers)) errors.push("triggers must be an object"); + else { + unknownKeys(rawTriggers, ["commands", "phrases", "automatic"], "triggers", errors); + const commands = stringList(rawTriggers["commands"], "triggers.commands", errors, SKILL_BOUNDS.maxCommandAliases, 40); + for (const command of commands) { + if (!COMMAND_PATTERN.test(command)) errors.push("triggers.commands entry must be lowercase kebab: " + command); + } + const automatic = rawTriggers["automatic"]; + if (automatic != null && typeof automatic !== "boolean") errors.push("triggers.automatic must be a boolean"); + triggers = { + commands, + phrases: stringList(rawTriggers["phrases"], "triggers.phrases", errors, SKILL_BOUNDS.maxTriggerPhrases, SKILL_BOUNDS.maxTriggerPhraseChars), + automatic: automatic === true, + }; + } + } + + // tools + let tools: SkillToolPolicy = { allowed: [], required: [], denied: [] }; + const rawTools = raw["tools"]; + if (rawTools != null) { + if (!isRecord(rawTools)) errors.push("tools must be an object"); + else { + unknownKeys(rawTools, ["allowed", "required", "denied"], "tools", errors); + const allowed = stringList(rawTools["allowed"], "tools.allowed", errors, TOOLS.length, 60); + const required = stringList(rawTools["required"], "tools.required", errors, TOOLS.length, 60); + const denied = stringList(rawTools["denied"], "tools.denied", errors, TOOLS.length, 60); + for (const list of [allowed, required, denied]) { + for (const tool of list) { + if (!TOOL_SET.has(tool)) errors.push("unknown tool name: " + tool); + } + } + const allowedSet = new Set(allowed); + for (const tool of required) { + if (!allowedSet.has(tool)) errors.push("tools.required must be a subset of tools.allowed: " + tool); + } + for (const tool of denied) { + if (allowedSet.has(tool)) errors.push("tools.denied must not intersect tools.allowed: " + tool); + } + tools = { allowed, required, denied }; + } + } + + // permissions + let permissions: SkillPermissionPolicy = { requires: [], mayRequest: [], forbids: [] }; + const rawPermissions = raw["permissions"]; + if (rawPermissions != null) { + if (!isRecord(rawPermissions)) errors.push("permissions must be an object"); + else { + unknownKeys(rawPermissions, ["requires", "may_request", "forbids"], "permissions", errors); + const parse = (key: string): PermissionName[] => { + const names = stringList(rawPermissions[key], "permissions." + key, errors, 24, 60); + const out: PermissionName[] = []; + for (const value of names) { + if (!isPermissionName(value)) { errors.push("unknown permission name: " + value); continue; } + out.push(value); + } + return out; + }; + const requires = parse("requires"); + const mayRequest = parse("may_request"); + const forbids = parse("forbids"); + for (const permission of [...requires, ...mayRequest]) { + if (SKILL_UNDECLARABLE_PERMISSIONS.includes(permission)) { + errors.push("permission '" + permission + "' cannot be declared by a skill"); + } + if (forbids.includes(permission)) { + errors.push("permission '" + permission + "' is both requested and forbidden"); + } + } + permissions = { requires, mayRequest, forbids }; + } + } + + // context + let context: SkillContextSpec = { maxTokens: 4000, maxResources: SKILL_BOUNDS.maxResources, resources: [] }; + const rawContext = raw["context"]; + if (rawContext != null) { + if (!isRecord(rawContext)) errors.push("context must be an object"); + else { + unknownKeys(rawContext, ["max_tokens", "max_resources", "resources"], "context", errors); + const maxTokens = rawContext["max_tokens"]; + if (maxTokens != null && (typeof maxTokens !== "number" || !Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > SKILL_BOUNDS.maxLoadedSkillTokens)) { + errors.push("context.max_tokens must be an integer from 1 to " + SKILL_BOUNDS.maxLoadedSkillTokens); + } + const resources = stringList(rawContext["resources"], "context.resources", errors, SKILL_BOUNDS.maxResources, 512); + for (const resource of resources) { + if (!isSafeRelativePath(resource)) errors.push("context.resources entry must be a safe relative path: " + resource); + } + context = { + maxTokens: typeof maxTokens === "number" ? maxTokens : 4000, + maxResources: SKILL_BOUNDS.maxResources, + resources, + }; + } + } + + // outputs + let outputs: SkillOutputSpec = { kinds: [], verification: [] }; + const rawOutputs = raw["outputs"]; + if (rawOutputs != null) { + if (!isRecord(rawOutputs)) errors.push("outputs must be an object"); + else { + unknownKeys(rawOutputs, ["kinds", "verification"], "outputs", errors); + outputs = { + kinds: stringList(rawOutputs["kinds"], "outputs.kinds", errors, 8, 60), + verification: stringList(rawOutputs["verification"], "outputs.verification", errors, 8, 120), + }; + } + } + + // dependencies + let dependencySkills: string[] = []; + const rawDependencies = raw["dependencies"]; + if (rawDependencies != null) { + if (!isRecord(rawDependencies)) errors.push("dependencies must be an object"); + else { + unknownKeys(rawDependencies, ["skills"], "dependencies", errors); + dependencySkills = stringList(rawDependencies["skills"], "dependencies.skills", errors, SKILL_BOUNDS.maxDependencies, 80); + for (const dependency of dependencySkills) { + if (!ID_PATTERN.test(dependency)) errors.push("dependencies.skills entry must be a fully qualified skill id: " + dependency); + if (dependency === id) errors.push("a skill cannot depend on itself"); + } + } + } + + // compatibility + let compatibility: SkillCompatibility = { minAgentVersion: "0.1.0", capabilityContract: 1 }; + const rawCompatibility = raw["compatibility"]; + if (rawCompatibility != null) { + if (!isRecord(rawCompatibility)) errors.push("compatibility must be an object"); + else { + unknownKeys(rawCompatibility, ["min_agent_version", "capability_contract"], "compatibility", errors); + const minAgentVersion = rawCompatibility["min_agent_version"]; + if (minAgentVersion != null && (typeof minAgentVersion !== "string" || !SEMVER_PATTERN.test(minAgentVersion))) { + errors.push("compatibility.min_agent_version must be strict semver"); + } + const capabilityContract = rawCompatibility["capability_contract"]; + if (capabilityContract != null && (typeof capabilityContract !== "number" || !Number.isInteger(capabilityContract) || capabilityContract < 1)) { + errors.push("compatibility.capability_contract must be a positive integer"); + } + compatibility = { + minAgentVersion: typeof minAgentVersion === "string" ? minAgentVersion : "0.1.0", + capabilityContract: typeof capabilityContract === "number" ? capabilityContract : 1, + }; + } + } + + // health + let evalManifest: string | null = null; + const rawHealth = raw["health"]; + if (rawHealth != null) { + if (!isRecord(rawHealth)) errors.push("health must be an object"); + else { + unknownKeys(rawHealth, ["eval_manifest"], "health", errors); + const manifest = rawHealth["eval_manifest"]; + if (manifest != null) { + if (typeof manifest !== "string" || !isSafeRelativePath(manifest)) { + errors.push("health.eval_manifest must be a safe relative path"); + } else { + evalManifest = manifest; + } + } + } + } + + if (errors.length) return { ok: false, errors }; + return { + ok: true, + manifest: { + schemaVersion: SKILL_SCHEMA_VERSION, + id, version, name, description, entrypoint, + triggers, tools, permissions, context, outputs, + dependencies: { skills: dependencySkills }, + compatibility, + health: { evalManifest }, + }, + }; +} + +/** Compare two strict-semver strings: negative when a < b. */ +export function compareSemver(a: string, b: string): number { + const parse = (value: string): number[] => value.split(".").map((part) => Number(part)); + const [aMajor = 0, aMinor = 0, aPatch = 0] = parse(a); + const [bMajor = 0, bMinor = 0, bPatch = 0] = parse(b); + return aMajor - bMajor || aMinor - bMinor || aPatch - bPatch; +} diff --git a/src/core/skills/skill_session.ts b/src/core/skills/skill_session.ts new file mode 100644 index 0000000..3cb0012 --- /dev/null +++ b/src/core/skills/skill_session.ts @@ -0,0 +1,115 @@ +// Per-run skill session assembly: discover → resolve → load → packet → policy. +// The one entry point the command layer calls; everything visible (header line, +// context summary) is produced here so every surface renders the same truth. + +import { discoverSkills } from "./skill_discovery.js"; +import { resolveExplicit, resolveAutomatic } from "./skill_resolver.js"; +import { loadSkillBody } from "./skill_loader.js"; +import { calculateSkillPolicy } from "./skill_policy.js"; +import { buildSkillContextPacket, approximateTokens, type SkillContextPacket } from "./context_packet.js"; +import { SkillError } from "./skill_errors.js"; +import { SKILL_BOUNDS } from "./skill_bounds.js"; +import { resolveInstructionGraph, buildInstructionContextPacket, type InstructionContextPacket } from "../instructions/instruction_resolver.js"; +import type { InstructionGraph } from "../instructions/instruction_types.js"; +import type { LoadedSkill, SkillPolicy } from "./skill_types.js"; +import { recordWhy } from "../why_log.js"; + +export interface SkillSessionOptions { + projectRoot: string; + prompt: string; + /** --skill explicit invocation (id, short name, or command alias). */ + explicitSkill?: string; + /** --no-skills: skip skills AND skill context entirely (deliberate). */ + noSkills?: boolean; + /** Injected for tests. */ + builtinRoot?: string; +} + +export interface SkillSession { + loaded: readonly LoadedSkill[]; + policies: readonly SkillPolicy[]; + packet: SkillContextPacket | null; + instructionPacket: InstructionContextPacket | null; + instructionGraph: InstructionGraph; + /** One-line run header, e.g. "Skills aether/fix-ci@1.1.0 · 3.4k tokens". */ + headerLines: readonly string[]; +} + +/** + * Assemble the skill + instruction context for one run. Throws SkillError with + * a stable code on any refusal (untrusted, ambiguous, budget, ...) — the + * command layer renders refusal + guidance and exits nonzero; it never + * downgrades a refusal into a silent skill-free run. + */ +export function prepareSkillSession(options: SkillSessionOptions): SkillSession { + const instructionGraph = resolveInstructionGraph(options.projectRoot); + const instructionPacket = buildInstructionContextPacket(instructionGraph.sources, null); + + if (options.noSkills) { + return { + loaded: [], + policies: [], + packet: null, + instructionPacket: instructionPacket.sources.length ? instructionPacket : null, + instructionGraph, + headerLines: headerFor([], instructionGraph), + }; + } + + const index = discoverSkills({ + projectRoot: options.projectRoot, + ...(options.builtinRoot ? { builtinRoot: options.builtinRoot } : {}), + }); + + const loaded: LoadedSkill[] = []; + if (options.explicitSkill) { + const resolved = resolveExplicit(index, options.explicitSkill); + // Dependencies load under the same explicit invocation as their target. + for (const descriptor of resolved.loadOrder) { + loaded.push(loadSkillBody(descriptor, "explicit")); + } + recordWhy("skill-selection", resolved.candidate.descriptor.id + "@" + resolved.candidate.descriptor.version + " — " + resolved.candidate.reason); + } + // Automatic candidates fill remaining slots; a skill already loaded + // explicitly is not loaded twice. + const loadedIds = new Set(loaded.map((skill) => skill.descriptor.id)); + for (const match of resolveAutomatic(index, options.prompt)) { + if (loadedIds.has(match.candidate.descriptor.id)) continue; + if (loaded.length >= SKILL_BOUNDS.maxSkillsPerTurn) break; + loaded.push(loadSkillBody(match.candidate.descriptor, "automatic")); + loadedIds.add(match.candidate.descriptor.id); + recordWhy("skill-selection", match.candidate.descriptor.id + " (automatic) — " + match.candidate.reason + " · confidence " + match.candidate.confidence.toFixed(2)); + } + + const packet = loaded.length ? buildSkillContextPacket(loaded) : null; + const policies = loaded.map((skill) => calculateSkillPolicy(skill)); + return { + loaded, + policies, + packet, + instructionPacket: instructionPacket.sources.length ? instructionPacket : null, + instructionGraph, + headerLines: headerFor(loaded, instructionGraph), + }; +} + +function headerFor(loaded: readonly LoadedSkill[], graph: InstructionGraph): string[] { + const lines: string[] = []; + if (loaded.length) { + const names = loaded.map((skill) => skill.descriptor.id + "@" + skill.descriptor.version).join(" · "); + const bytes = loaded.reduce((sum, skill) => sum + skill.loadedBytes, 0); + const tokens = approximateTokens(bytes); + const display = tokens >= 1000 ? (tokens / 1000).toFixed(1) + "k" : String(tokens); + lines.push("Skills " + names); + lines.push("Context " + display + " tokens · " + graph.sources.length + " instruction source" + (graph.sources.length === 1 ? "" : "s")); + } else if (graph.sources.length) { + lines.push("Rules " + graph.sources.map((source) => source.displayPath).join(" + ")); + } + for (const conflict of graph.conflicts) { + lines.push("Conflict " + conflict.topic + " — effective: " + conflict.effective + " (" + conflict.reason + ")"); + recordWhy("instruction-conflict", conflict.topic + ": effective '" + conflict.effective + "' — " + conflict.reason); + } + return lines; +} + +export { SkillError }; diff --git a/src/core/skills/skill_settings.ts b/src/core/skills/skill_settings.ts new file mode 100644 index 0000000..98817a5 --- /dev/null +++ b/src/core/skills/skill_settings.ts @@ -0,0 +1,80 @@ +// Per-skill local settings: enabled/disabled + automatic-selection opt-in. +// Local file (/skill-settings.json) — like trust, never committed. +// Absence of a record means: enabled, automatic only if the manifest says so +// AND the scope defaults allow it (project skills additionally need trust). + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { configDir } from "../config.js"; + +export const SKILL_SETTINGS_SCHEMA_VERSION = 1; + +export interface SkillSetting { + /** "*" for user/builtin scope, canonical project root for project skills. */ + projectRoot: string; + skillId: string; + enabled: boolean; + /** Explicit opt-in to automatic selection (user + trusted project skills). */ + automatic: boolean; +} + +export interface SkillSettingsStore { + schemaVersion: number; + settings: readonly SkillSetting[]; +} + +export function skillSettingsPath(): string { + return join(configDir(), "skill-settings.json"); +} + +export function loadSkillSettings(): SkillSettingsStore { + const path = skillSettingsPath(); + if (!existsSync(path)) return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings: [] }; + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (raw["schema_version"] !== SKILL_SETTINGS_SCHEMA_VERSION || !Array.isArray(raw["settings"])) { + return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings: [] }; + } + const settings: SkillSetting[] = []; + for (const entry of raw["settings"] as unknown[]) { + if (typeof entry !== "object" || entry === null) continue; + const item = entry as Record; + if (typeof item["projectRoot"] !== "string" || typeof item["skillId"] !== "string") continue; + settings.push({ + projectRoot: item["projectRoot"], + skillId: item["skillId"], + enabled: item["enabled"] !== false, + automatic: item["automatic"] === true, + }); + } + return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings }; + } catch { + return { schemaVersion: SKILL_SETTINGS_SCHEMA_VERSION, settings: [] }; + } +} + +export function saveSkillSetting(setting: SkillSetting): void { + const store = loadSkillSettings(); + const rest = store.settings.filter( + (existing) => !(existing.projectRoot === setting.projectRoot && existing.skillId === setting.skillId), + ); + mkdirSync(configDir(), { recursive: true, mode: 0o700 }); + const path = skillSettingsPath(); + const body = { + schema_version: SKILL_SETTINGS_SCHEMA_VERSION, + settings: [...rest, setting], + }; + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +export function lookupSkillSetting( + store: SkillSettingsStore, + projectRoot: string, + skillId: string, +): SkillSetting | undefined { + return store.settings.find( + (setting) => setting.projectRoot === projectRoot && setting.skillId === skillId, + ); +} diff --git a/src/core/skills/skill_trust.ts b/src/core/skills/skill_trust.ts new file mode 100644 index 0000000..0481806 --- /dev/null +++ b/src/core/skills/skill_trust.ts @@ -0,0 +1,136 @@ +// Local trust store: /skill-trust.json +// +// Trust is a LOCAL decision — never committed to a project. It binds to +// (project identity, skill id, version, exact content digest). A digest change +// moves the skill to "changed · review required"; nothing implicit re-trusts it. +// Built-ins are trusted via the signed package artifact and never appear here. + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { configDir } from "../config.js"; + +export const SKILL_TRUST_SCHEMA_VERSION = 1; + +export type TrustMethod = "inspect" | "explicit" | "install"; + +export interface SkillTrustRecord { + /** Canonical absolute project root the trust applies to ("*" for user-scope skills). */ + projectRoot: string; + /** Repository host/slug when known, e.g. "github.com/AetherAI3/aether-agent". */ + repository: string | null; + skillId: string; + version: string; + sha256: string; + trustedAt: string; + method: TrustMethod; + /** Requested permission summary shown at trust time — audit trail only. */ + requestedPermissions: readonly string[]; +} + +export interface SkillTrustStore { + schemaVersion: number; + records: readonly SkillTrustRecord[]; +} + +export function trustStorePath(): string { + return join(configDir(), "skill-trust.json"); +} + +export function loadTrustStore(): SkillTrustStore { + const path = trustStorePath(); + if (!existsSync(path)) return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: [] }; + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + if (raw["schema_version"] !== SKILL_TRUST_SCHEMA_VERSION || !Array.isArray(raw["records"])) { + // Unknown shape: treat as empty rather than guessing — callers see skills + // as untrusted, which fails closed. + return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: [] }; + } + const records: SkillTrustRecord[] = []; + for (const entry of raw["records"] as unknown[]) { + if (typeof entry !== "object" || entry === null) continue; + const item = entry as Record; + if ( + typeof item["projectRoot"] !== "string" || + typeof item["skillId"] !== "string" || + typeof item["version"] !== "string" || + typeof item["sha256"] !== "string" || + typeof item["trustedAt"] !== "string" || + (item["method"] !== "inspect" && item["method"] !== "explicit" && item["method"] !== "install") + ) continue; + records.push({ + projectRoot: item["projectRoot"], + repository: typeof item["repository"] === "string" ? item["repository"] : null, + skillId: item["skillId"], + version: item["version"], + sha256: item["sha256"], + trustedAt: item["trustedAt"], + method: item["method"], + requestedPermissions: Array.isArray(item["requestedPermissions"]) + ? (item["requestedPermissions"] as unknown[]).filter((p): p is string => typeof p === "string") + : [], + }); + } + return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records }; + } catch { + return { schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: [] }; + } +} + +function saveTrustStore(store: SkillTrustStore): void { + mkdirSync(configDir(), { recursive: true, mode: 0o700 }); + const path = trustStorePath(); + const body = { + schema_version: SKILL_TRUST_SCHEMA_VERSION, + records: store.records, + }; + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + renameSync(tmp, path); +} + +export type TrustLookup = + | { state: "trusted"; record: SkillTrustRecord } + | { state: "changed"; record: SkillTrustRecord } + | { state: "untrusted" }; + +/** + * Look up trust for one (projectRoot, skillId). "changed" means a record exists + * for this id but with a DIFFERENT digest — the caller must show a diff and + * require re-trust; it must never treat "changed" as trusted. + */ +export function lookupTrust( + store: SkillTrustStore, + projectRoot: string, + skillId: string, + sha256: string, +): TrustLookup { + const matches = store.records.filter( + (record) => record.projectRoot === projectRoot && record.skillId === skillId, + ); + const exact = matches.find((record) => record.sha256 === sha256); + if (exact) return { state: "trusted", record: exact }; + const latest = matches[matches.length - 1]; + if (latest) return { state: "changed", record: latest }; + return { state: "untrusted" }; +} + +export function recordTrust(record: SkillTrustRecord): void { + const store = loadTrustStore(); + // One live record per (projectRoot, skillId): re-trusting replaces the old + // digest binding instead of accumulating stale ones. + const rest = store.records.filter( + (existing) => !(existing.projectRoot === record.projectRoot && existing.skillId === record.skillId), + ); + saveTrustStore({ schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: [...rest, record] }); +} + +export function removeTrust(projectRoot: string, skillId: string): boolean { + const store = loadTrustStore(); + const rest = store.records.filter( + (existing) => !(existing.projectRoot === projectRoot && existing.skillId === skillId), + ); + if (rest.length === store.records.length) return false; + saveTrustStore({ schemaVersion: SKILL_TRUST_SCHEMA_VERSION, records: rest }); + return true; +} diff --git a/src/core/skills/skill_types.ts b/src/core/skills/skill_types.ts new file mode 100644 index 0000000..d243af8 --- /dev/null +++ b/src/core/skills/skill_types.ts @@ -0,0 +1,84 @@ +// Typed boundary for skill discovery, resolution, and loading. +// No anonymous object bags cross this boundary (spec §5). + +import type { SkillManifest, SkillScope } from "./skill_schema.js"; +import type { PermissionName } from "./permission_vocabulary.js"; + +export type SkillTrustState = + | "trusted" // digest matches a recorded trust decision (or signed built-in) + | "untrusted" // project skill with no trust record + | "changed" // trust record exists for a DIFFERENT digest — review required + | "builtin"; // shipped inside the signed package artifact + +export interface SkillDescriptor { + /** Fully qualified id, e.g. "project/review-pr" or "aether/fix-ci". */ + id: string; + version: string; + name: string; + description: string; + scope: SkillScope; + /** Absolute skill root on disk (or package resource root for built-ins). */ + root: string; + /** Canonical content digest — sha256 hex. */ + sha256: string; + trust: SkillTrustState; + enabled: boolean; + /** Enabled for bounded automatic selection (requires trust). */ + automatic: boolean; + /** Approximate context cost if loaded (from manifest.context.maxTokens). */ + approxTokens: number; + manifest: SkillManifest; +} + +/** A skill that failed validation — indexed so `skills list` can show WHY, never loadable. */ +export interface SkillIndexError { + root: string; + scope: SkillScope; + errors: readonly string[]; +} + +export interface SkillIndex { + skills: readonly SkillDescriptor[]; + errors: readonly SkillIndexError[]; + generatedAt: string; +} + +export type SkillInvocationKind = "explicit" | "automatic"; + +export interface SkillCandidate { + descriptor: SkillDescriptor; + invocation: SkillInvocationKind; + /** Why the resolver matched it — shown to the user, never empty. */ + reason: string; + /** 0..1 resolver confidence; explicit invocation is always 1. */ + confidence: number; +} + +export interface ResolvedSkill { + candidate: SkillCandidate; + /** Dependency-ordered descriptors, dependencies first, target last. */ + loadOrder: readonly SkillDescriptor[]; +} + +export interface LoadedSkillResource { + name: string; + sha256: string; + content: string; +} + +export interface LoadedSkill { + descriptor: SkillDescriptor; + invocation: SkillInvocationKind; + instructions: string; + resources: readonly LoadedSkillResource[]; + /** Bytes actually loaded (instructions + resources). */ + loadedBytes: number; +} + +/** The effective, already-intersected policy the host enforces per tool call. */ +export interface SkillPolicy { + skillId: string; + allowedTools: readonly string[]; + requiredPermissions: readonly PermissionName[]; + forbiddenPermissions: readonly PermissionName[]; +} diff --git a/src/core/why_log.ts b/src/core/why_log.ts new file mode 100644 index 0000000..266efcf --- /dev/null +++ b/src/core/why_log.ts @@ -0,0 +1,45 @@ +// /why — a bounded in-process explanation log. Skill selection, refusals, +// permission denials, capability warnings, doctor warnings, and instruction +// conflicts record one line each; /why replays the latest so the user never +// gets an opaque internal exception as the only answer. + +export type WhyKind = + | "skill-selection" + | "skill-refusal" + | "permission-denial" + | "capability" + | "doctor" + | "provider-fallback" + | "context-clipping" + | "instruction-conflict"; + +export interface WhyEntry { + kind: WhyKind; + text: string; + at: string; +} + +const MAX_ENTRIES = 50; +const entries: WhyEntry[] = []; + +export function recordWhy(kind: WhyKind, text: string, now = new Date()): void { + entries.push({ kind, text, at: now.toISOString() }); + if (entries.length > MAX_ENTRIES) entries.splice(0, entries.length - MAX_ENTRIES); +} + +export function latestWhy(limit = 10): readonly WhyEntry[] { + return entries.slice(-limit); +} + +export function renderWhy(limit = 10): string { + const latest = latestWhy(limit); + if (!latest.length) { + return "nothing to explain yet — skill selections, refusals, conflicts, and capability warnings appear here.\n"; + } + return latest.map((entry) => "[" + entry.kind + "] " + entry.text).join("\n") + "\n"; +} + +/** Test hook: reset the log. */ +export function clearWhy(): void { + entries.length = 0; +} diff --git a/test/instruction_resolver.test.ts b/test/instruction_resolver.test.ts new file mode 100644 index 0000000..a71a2e0 --- /dev/null +++ b/test/instruction_resolver.test.ts @@ -0,0 +1,192 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverInstructionSources, parseCursorGlobs } from "../src/core/instructions/instruction_discovery.js"; +import { + applicableSources, + buildInstructionContextPacket, + detectConflicts, + extractTestCommands, + resolveInstructionGraph, + sourceAppliesTo, +} from "../src/core/instructions/instruction_resolver.js"; +import { SKILL_BOUNDS } from "../src/core/skills/skill_bounds.js"; + +function withEnv(key: string, value: string, fn: () => T): T { + const prior = process.env[key]; + process.env[key] = value; + try { + return fn(); + } finally { + if (prior == null) delete process.env[key]; + else process.env[key] = prior; + } +} + +function makeProject(): string { + return mkdtempSync(join(tmpdir(), "aether-instr-")); +} + +test("discovers root AGENTS.md and canonical Aether instructions", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), "Use npm test for everything.\n"); + mkdirSync(join(root, ".aether"), { recursive: true }); + writeFileSync(join(root, ".aether", "instructions.md"), "Run npm run test:ci before shipping.\n"); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources, skipped } = discoverInstructionSources(root); + assert.equal(skipped.length, 0); + const kinds = sources.map((source) => source.kind).sort(); + assert.deepEqual(kinds, ["aether-project", "agents-root"]); + for (const source of sources) { + assert.match(source.sha256, /^[0-9a-f]{64}$/); + assert.equal(source.parseStatus, "ok"); + } + }); +}); + +test("nested AGENTS.md scopes to its subtree only", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), "root guidance\n"); + mkdirSync(join(root, "packages", "web"), { recursive: true }); + writeFileSync(join(root, "packages", "web", "AGENTS.md"), "web guidance\n"); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources } = discoverInstructionSources(root); + const nested = sources.find((source) => source.kind === "agents-nested"); + assert.ok(nested); + assert.equal(nested.scopeDir, "packages/web"); + assert.equal(sourceAppliesTo(nested, "packages/web/app.tsx"), true); + assert.equal(sourceAppliesTo(nested, "src/other.ts"), false); + assert.equal(sourceAppliesTo(nested, null), false); + // Nearest nested outranks root for files in its subtree. + const ordered = applicableSources(sources, "packages/web/app.tsx"); + assert.equal(ordered[0]?.kind, "agents-nested"); + }); +}); + +test("precedence: canonical Aether project instruction beats root AGENTS.md", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), "Always run `npm test`.\n"); + mkdirSync(join(root, ".aether"), { recursive: true }); + writeFileSync(join(root, ".aether", "instructions.md"), "Always run `npm run test:ci`.\n"); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const graph = resolveInstructionGraph(root); + assert.equal(graph.conflicts.length, 1); + const conflict = graph.conflicts[0]; + assert.ok(conflict); + assert.equal(conflict.topic, "test command"); + assert.equal(conflict.effective, "npm run test:ci"); + assert.match(conflict.reason, /canonical Aether project instruction/); + }); +}); + +test("extractTestCommands finds common runners and ignores prose", () => { + const commands = extractTestCommands( + "Run `npm run test:ci` locally. CI uses pytest tests/api. Never cargo test --all here.\nThis is the greatest codebase.\n", + ); + assert.ok(commands.includes("npm run test:ci")); + assert.ok(commands.some((command) => command.startsWith("pytest"))); + assert.ok(commands.some((command) => command.startsWith("cargo test"))); + assert.ok(!commands.some((command) => command.includes("greatest"))); +}); + +test("no conflict when sources agree", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), "Use `npm test`.\n"); + writeFileSync(join(root, "CLAUDE.md"), "Use `npm test`.\n"); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const graph = resolveInstructionGraph(root); + assert.equal(graph.conflicts.length, 0); + }); +}); + +test("cursor rule with supported globs scopes by pattern", () => { + const root = makeProject(); + mkdirSync(join(root, ".cursor", "rules"), { recursive: true }); + writeFileSync( + join(root, ".cursor", "rules", "ts.mdc"), + "---\nglobs: src/**/*.ts\n---\nPrefer type-only imports.\n", + ); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources } = discoverInstructionSources(root); + const rule = sources.find((source) => source.kind === "cursor-rule"); + assert.ok(rule); + assert.deepEqual(rule.globs, ["src/**/*.ts"]); + assert.equal(sourceAppliesTo(rule, "src/core/x.ts"), true); + assert.equal(sourceAppliesTo(rule, "docs/x.md"), false); + assert.equal(sourceAppliesTo(rule, null), false); + }); +}); + +test("cursor rule with unsupported glob syntax warns and is NOT applied globally", () => { + const parsed = parseCursorGlobs("---\nglobs: [src/**, {a,b}/*.ts]\n---\nbody\n"); + assert.deepEqual(parsed.globs, []); + assert.equal(parsed.warnings.length, 1); + assert.match(parsed.warnings[0] ?? "", /unsupported/); + + const root = makeProject(); + mkdirSync(join(root, ".cursor", "rules"), { recursive: true }); + writeFileSync(join(root, ".cursor", "rules", "bad.mdc"), "---\nglobs: {a,b}/*.ts\n---\nrule body\n"); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources } = discoverInstructionSources(root); + const rule = sources.find((source) => source.kind === "cursor-rule"); + assert.ok(rule); + assert.equal(rule.parseStatus, "unsupported-syntax"); + assert.equal(sourceAppliesTo(rule, "a/x.ts"), false); + assert.equal(sourceAppliesTo(rule, null), false); + }); +}); + +test("oversized instruction file is truncated with a visible warning", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), "x".repeat(SKILL_BOUNDS.maxInstructionFileBytes + 100)); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources } = discoverInstructionSources(root); + const source = sources[0]; + assert.ok(source); + assert.equal(source.parseStatus, "truncated"); + assert.ok(source.warnings.some((warning) => warning.includes("truncated"))); + }); +}); + +test("binary instruction file is skipped with a reason, not applied", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), Buffer.from([0x41, 0x00, 0x42, 0x00, 0x43])); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources, skipped } = discoverInstructionSources(root); + assert.equal(sources.length, 0); + assert.equal(skipped.length, 1); + assert.match(skipped[0]?.reason ?? "", /binary/); + }); +}); + +test("context packet carries provenance and applies scoping", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), "root\n"); + mkdirSync(join(root, "api"), { recursive: true }); + writeFileSync(join(root, "api", "AGENTS.md"), "api only\n"); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources } = discoverInstructionSources(root); + const packetForApi = buildInstructionContextPacket(sources, "api/server.py"); + assert.equal(packetForApi.contract_version, 1); + assert.equal(packetForApi.sources.length, 2); + assert.equal(packetForApi.sources[0]?.kind, "agents-nested"); + assert.match(packetForApi.sources[0]?.digest ?? "", /^sha256:[0-9a-f]{64}$/); + const packetGlobal = buildInstructionContextPacket(sources, null); + assert.equal(packetGlobal.sources.length, 1); + assert.equal(packetGlobal.sources[0]?.kind, "agents-root"); + }); +}); + +test("detectConflicts keeps highest-precedence command as effective", () => { + const root = makeProject(); + writeFileSync(join(root, "AGENTS.md"), "`npm test`\n"); + writeFileSync(join(root, "CLAUDE.md"), "`pytest tests`\n"); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const { sources } = discoverInstructionSources(root); + const conflicts = detectConflicts(applicableSources(sources, null)); + assert.equal(conflicts.length, 1); + assert.equal(conflicts[0]?.effective, "npm test"); + }); +}); diff --git a/test/skill_digest.test.ts b/test/skill_digest.test.ts new file mode 100644 index 0000000..04be7d3 --- /dev/null +++ b/test/skill_digest.test.ts @@ -0,0 +1,162 @@ +// Canonical skill digest: deterministic over declared content only, bounded, +// and symlink-escape safe. + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + calculateSkillDigest, + canonicalJson, + digestFileList, + sha256Hex, +} from "../src/core/skills/skill_digest.js"; +import { validateSkillManifest, type SkillManifest } from "../src/core/skills/skill_schema.js"; +import { SKILL_BOUNDS } from "../src/core/skills/skill_bounds.js"; + +let base: string; +let counter = 0; + +before(() => { + base = mkdtempSync(join(tmpdir(), "aether-digest-")); +}); + +after(() => { + rmSync(base, { recursive: true, force: true }); +}); + +function rawManifest(resources: readonly string[] = []): Record { + return { + schema_version: 1, + id: "project/digest-demo", + version: "1.0.0", + name: "Digest Demo", + description: "Fixture skill for digest tests.", + context: { resources: [...resources] }, + }; +} + +function parseManifest(raw: Record): SkillManifest { + const validation = validateSkillManifest(raw, "project"); + assert.equal(validation.ok, true, JSON.stringify(validation.ok ? [] : validation.errors)); + if (!validation.ok) throw new Error("unreachable"); + return validation.manifest; +} + +function makeSkillDir(files: Readonly>): string { + const root = join(base, "skill-" + counter++); + mkdirSync(root, { recursive: true }); + for (const [relative, content] of Object.entries(files)) { + const full = join(root, relative); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + } + return root; +} + +function digestOf(root: string, raw: Record): string { + const result = calculateSkillDigest(root, parseManifest(raw), raw); + assert.equal(result.ok, true, result.ok ? "" : result.error); + if (!result.ok) throw new Error("unreachable"); + return result.sha256; +} + +test("canonicalJson sorts keys at every level", () => { + const a = canonicalJson({ b: 1, a: { d: [1, 2], c: null } }); + const b = canonicalJson({ a: { c: null, d: [1, 2] }, b: 1 }); + assert.equal(a, b); + assert.equal(a, '{"a":{"c":null,"d":[1,2]},"b":1}'); +}); + +test("digest is deterministic across manifest key order", () => { + const root = makeSkillDir({ "SKILL.md": "# body\n", "res.md": "resource\n" }); + const rawA = rawManifest(["res.md"]); + const rawB: Record = { + context: { resources: ["res.md"] }, + description: "Fixture skill for digest tests.", + name: "Digest Demo", + version: "1.0.0", + id: "project/digest-demo", + schema_version: 1, + }; + assert.equal(digestOf(root, rawA), digestOf(root, rawB)); +}); + +test("changing one SKILL.md byte changes the digest", () => { + const root = makeSkillDir({ "SKILL.md": "instructions v1\n" }); + const raw = rawManifest(); + const first = digestOf(root, raw); + writeFileSync(join(root, "SKILL.md"), "instructions v2\n"); + const second = digestOf(root, raw); + assert.notEqual(first, second); +}); + +test("changing a declared resource changes the digest", () => { + const root = makeSkillDir({ "SKILL.md": "body\n", "notes.md": "one\n" }); + const raw = rawManifest(["notes.md"]); + const first = digestOf(root, raw); + writeFileSync(join(root, "notes.md"), "two\n"); + assert.notEqual(first, digestOf(root, raw)); +}); + +test("an undeclared file never contributes to the digest", () => { + const root = makeSkillDir({ "SKILL.md": "body\n" }); + const raw = rawManifest(); + const first = digestOf(root, raw); + writeFileSync(join(root, "stray.md"), "does not count\n"); + assert.equal(first, digestOf(root, raw)); +}); + +test("missing declared file fails the digest", () => { + const root = makeSkillDir({ "SKILL.md": "body\n" }); + const raw = rawManifest(["ghost.md"]); + const result = calculateSkillDigest(root, parseManifest(raw), raw); + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /declared file missing: ghost\.md/); +}); + +test("declared file over the byte cap fails", () => { + const oversized = Buffer.alloc(SKILL_BOUNDS.maxResourceBytes + 1, 0x61); + const root = makeSkillDir({ "SKILL.md": "body\n", "big.md": oversized }); + const raw = rawManifest(["big.md"]); + const result = calculateSkillDigest(root, parseManifest(raw), raw); + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /exceeds \d+ bytes: big\.md/); +}); + +test("symlink escaping the skill root fails", (t) => { + const outside = join(base, "outside-secret.md"); + writeFileSync(outside, "secret outside the root\n"); + const root = makeSkillDir({ "SKILL.md": "body\n" }); + try { + symlinkSync(outside, join(root, "link.md"), "file"); + } catch (error) { + // Windows without Developer Mode refuses symlink creation (EPERM). + const code = (error as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES") { + t.skip("symlink creation not permitted on this host"); + return; + } + throw error; + } + const raw = rawManifest(["link.md"]); + const result = calculateSkillDigest(root, parseManifest(raw), raw); + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /escapes the skill root: link\.md/); +}); + +test("digestFileList sorts and deduplicates, sha256Hex matches node crypto shape", () => { + const raw = rawManifest(["z.md", "a.md"]); + raw["health"] = { eval_manifest: "evals/e.json" }; + const files = digestFileList(parseManifest(raw)); + assert.deepEqual(files, ["SKILL.md", "a.md", "evals/e.json", "z.md"]); + assert.match(sha256Hex("abc"), /^[0-9a-f]{64}$/); + assert.equal( + sha256Hex("abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); +}); diff --git a/test/skill_discovery_resolution.test.ts b/test/skill_discovery_resolution.test.ts new file mode 100644 index 0000000..e04b25c --- /dev/null +++ b/test/skill_discovery_resolution.test.ts @@ -0,0 +1,252 @@ +// Discovery over builtin/user/project roots + explicit/automatic resolution. +// Metadata-only indexing, trust folded from the local store, structured errors. + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { discoverSkills, projectSkillsRoot, userSkillsRoot } from "../src/core/skills/skill_discovery.js"; +import { resolveExplicit, resolveAutomatic, dependencyOrder } from "../src/core/skills/skill_resolver.js"; +import { recordTrust } from "../src/core/skills/skill_trust.js"; +import { saveSkillSetting } from "../src/core/skills/skill_settings.js"; +import { SkillError } from "../src/core/skills/skill_errors.js"; +import { SKILL_BOUNDS } from "../src/core/skills/skill_bounds.js"; +import type { SkillIndex, SkillDescriptor } from "../src/core/skills/skill_types.js"; + +let configDir: string; +let projectRoot: string; +let builtinRoot: string; + +function manifest(id: string, extra: Record = {}): Record { + const shortName = id.split("/")[1] ?? id; + return { + schema_version: 1, + id, + version: "1.0.0", + name: "Skill " + shortName, + description: "Fixture skill " + id + " for discovery tests.", + ...extra, + }; +} + +function writeSkill(scopeRoot: string, dirName: string, raw: Record, body = "# instructions\n"): string { + const root = join(scopeRoot, dirName); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "skill.json"), JSON.stringify(raw, null, 2), "utf8"); + writeFileSync(join(root, "SKILL.md"), body, "utf8"); + return root; +} + +// Tests share one process (--test-isolation=none), so the env override is set +// and restored around every call that touches configDir(), never left global. +function withEnv(fn: () => T): T { + const prev = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = configDir; + try { + return fn(); + } finally { + if (prev === undefined) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = prev; + } +} + +function discover(): SkillIndex { + return withEnv(() => discoverSkills({ projectRoot, builtinRoot, now: () => new Date("2026-08-14T12:00:00Z") })); +} + +function byId(index: SkillIndex, id: string): SkillDescriptor { + const descriptor = index.skills.find((skill) => skill.id === id); + assert.ok(descriptor, "descriptor missing for " + id); + return descriptor; +} + +function skillErrorCode(fn: () => unknown): string { + try { + fn(); + } catch (error) { + assert.ok(error instanceof SkillError, "expected SkillError, got " + String(error)); + return error.code; + } + assert.fail("expected a SkillError to be thrown"); +} + +before(() => { + configDir = mkdtempSync(join(tmpdir(), "aether-disc-cfg-")); + projectRoot = mkdtempSync(join(tmpdir(), "aether-disc-proj-")); + builtinRoot = mkdtempSync(join(tmpdir(), "aether-disc-builtin-")); + + const projRoot = projectSkillsRoot(projectRoot); + const userRoot = withEnv(() => userSkillsRoot()); + assert.equal(userRoot, join(configDir, "skills", "user")); + + writeSkill(projRoot, "alpha", manifest("project/alpha", { + triggers: { phrases: ["alpha project phrase"], automatic: true }, + }), "# alpha body v1\n"); + writeSkill(projRoot, "tool", manifest("project/tool")); + writeSkill(userRoot, "tool", manifest("user/tool")); + writeSkill(userRoot, "beta", manifest("user/beta", { triggers: { commands: ["beta-cmd"] } })); + writeSkill(userRoot, "off", manifest("user/off")); + writeSkill(userRoot, "base", manifest("user/base")); + writeSkill(userRoot, "child", manifest("user/child", { dependencies: { skills: ["user/base"] } })); + writeSkill(userRoot, "orphan", manifest("user/orphan", { dependencies: { skills: ["user/ghost"] } })); + writeSkill(userRoot, "cyc-a", manifest("user/cyc-a", { dependencies: { skills: ["user/cyc-b"] } })); + writeSkill(userRoot, "cyc-b", manifest("user/cyc-b", { dependencies: { skills: ["user/cyc-a"] } })); + + for (let index = 1; index <= SKILL_BOUNDS.maxAutomaticSkillsPerTurn + 1; index++) { + writeSkill(builtinRoot, "auto" + index, manifest("builtin/auto" + index, { + triggers: { phrases: ["shared builtin trigger phrase"], automatic: true }, + })); + } + + // duplicate fully qualified id across two directories + writeSkill(projRoot, "dup-one", manifest("project/dupped")); + writeSkill(projRoot, "dup-two", manifest("project/dupped")); + + // malformed manifest — must land in index.errors, never throw + const brokenRoot = join(projRoot, "broken"); + mkdirSync(brokenRoot, { recursive: true }); + writeFileSync(join(brokenRoot, "skill.json"), "{ not json at all", "utf8"); + + withEnv(() => saveSkillSetting({ projectRoot: "*", skillId: "user/off", enabled: false, automatic: false })); +}); + +after(() => { + rmSync(configDir, { recursive: true, force: true }); + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(builtinRoot, { recursive: true, force: true }); +}); + +test("index has correct scopes and default trust states", () => { + const index = discover(); + assert.equal(byId(index, "project/alpha").scope, "project"); + assert.equal(byId(index, "project/alpha").trust, "untrusted"); + assert.equal(byId(index, "project/alpha").automatic, false, "untrusted project skill never automatic"); + assert.equal(byId(index, "user/beta").scope, "user"); + assert.equal(byId(index, "user/beta").trust, "trusted"); + assert.equal(byId(index, "builtin/auto1").scope, "builtin"); + assert.equal(byId(index, "builtin/auto1").trust, "builtin"); + assert.equal(byId(index, "builtin/auto1").automatic, true); + assert.equal(byId(index, "user/off").enabled, false); +}); + +test("duplicate skill id and malformed manifest are index errors, not throws", () => { + const index = discover(); + assert.ok( + index.errors.some((error) => error.errors.some((message) => message.includes("duplicate skill id: project/dupped"))), + "duplicate id error missing", + ); + assert.ok( + index.errors.some((error) => error.root.endsWith("broken") && error.errors.some((message) => message.includes("not valid JSON"))), + "malformed skill.json error missing", + ); + assert.equal(index.skills.filter((skill) => skill.id === "project/dupped").length, 1); +}); + +test("untrusted project skill refuses explicit resolution", () => { + const index = discover(); + assert.equal(skillErrorCode(() => resolveExplicit(index, "project/alpha")), "skill.untrusted"); +}); + +test("recordTrust with the resolved project root makes the skill trusted, edits flip it to changed", () => { + let index = discover(); + const alpha = byId(index, "project/alpha"); + withEnv(() => recordTrust({ + projectRoot: resolve(projectRoot), + repository: null, + skillId: alpha.id, + version: alpha.version, + sha256: alpha.sha256, + trustedAt: new Date().toISOString(), + method: "inspect", + requestedPermissions: [], + })); + index = discover(); + assert.equal(byId(index, "project/alpha").trust, "trusted"); + const resolved = resolveExplicit(index, "project/alpha"); + assert.equal(resolved.candidate.descriptor.id, "project/alpha"); + assert.equal(resolved.candidate.invocation, "explicit"); + + writeFileSync(join(projectSkillsRoot(projectRoot), "alpha", "SKILL.md"), "# alpha body v2 EDITED\n", "utf8"); + index = discover(); + assert.equal(byId(index, "project/alpha").trust, "changed"); + assert.equal(skillErrorCode(() => resolveExplicit(index, "project/alpha")), "skill.changed"); +}); + +test("resolveExplicit: full id, unique short name, command alias", () => { + const index = discover(); + assert.equal(resolveExplicit(index, "user/beta").candidate.descriptor.id, "user/beta"); + assert.equal(resolveExplicit(index, "beta").candidate.descriptor.id, "user/beta"); + assert.equal(resolveExplicit(index, "beta-cmd").candidate.descriptor.id, "user/beta"); +}); + +test("short name shared across scopes is ambiguous", () => { + const index = discover(); + try { + resolveExplicit(index, "tool"); + assert.fail("expected ambiguity"); + } catch (error) { + assert.ok(error instanceof SkillError); + assert.equal(error.code, "skill.ambiguous"); + const matches = error.refusal.context?.["matches"]; + assert.ok(Array.isArray(matches) && matches.length === 2, "ambiguity lists both candidates"); + } +}); + +test("disabled skill refuses with skill.disabled", () => { + const index = discover(); + assert.equal(skillErrorCode(() => resolveExplicit(index, "user/off")), "skill.disabled"); +}); + +test("unknown reference is skill.not_found", () => { + const index = discover(); + assert.equal(skillErrorCode(() => resolveExplicit(index, "user/never-existed")), "skill.not_found"); +}); + +test("dependencyOrder puts dependencies first", () => { + const index = discover(); + const order = dependencyOrder(index, byId(index, "user/child")); + assert.deepEqual(order.map((descriptor) => descriptor.id), ["user/base", "user/child"]); +}); + +test("missing dependency is skill.dependency_missing", () => { + const index = discover(); + assert.equal( + skillErrorCode(() => dependencyOrder(index, byId(index, "user/orphan"))), + "skill.dependency_missing", + ); +}); + +test("dependency cycle is skill.dependency_cycle", () => { + const index = discover(); + assert.equal( + skillErrorCode(() => dependencyOrder(index, byId(index, "user/cyc-a"))), + "skill.dependency_cycle", + ); +}); + +test("resolveAutomatic matches only automatic trusted skills by phrase", () => { + const index = discover(); + // project/alpha declares automatic + a phrase but has no trusted opt-in. + const alphaMatches = resolveAutomatic(index, "please run the alpha project phrase now"); + assert.equal(alphaMatches.length, 0, "non-automatic project skill must not match"); + + const none = resolveAutomatic(index, "prompt without any trigger"); + assert.equal(none.length, 0); + + const matches = resolveAutomatic(index, "do the shared builtin trigger phrase please"); + assert.ok(matches.length > 0); + for (const match of matches) { + assert.equal(match.candidate.invocation, "automatic"); + assert.match(match.candidate.reason, /trigger phrase/); + assert.ok(match.candidate.descriptor.id.startsWith("builtin/auto")); + } +}); + +test("resolveAutomatic caps at SKILL_BOUNDS.maxAutomaticSkillsPerTurn", () => { + const index = discover(); + const eligible = index.skills.filter((skill) => skill.automatic).length; + assert.ok(eligible > SKILL_BOUNDS.maxAutomaticSkillsPerTurn, "fixture must exceed the cap"); + const matches = resolveAutomatic(index, "do the shared builtin trigger phrase please"); + assert.equal(matches.length, SKILL_BOUNDS.maxAutomaticSkillsPerTurn); +}); diff --git a/test/skill_lazy_loading.test.ts b/test/skill_lazy_loading.test.ts new file mode 100644 index 0000000..5186955 --- /dev/null +++ b/test/skill_lazy_loading.test.ts @@ -0,0 +1,146 @@ +// The lazy-loading contract: discovery retains metadata only — never body +// text — and loadSkillBody re-reads with digest re-verification (TOCTOU-safe). + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverSkills, projectSkillsRoot } from "../src/core/skills/skill_discovery.js"; +import { loadSkillBody } from "../src/core/skills/skill_loader.js"; +import { SkillError } from "../src/core/skills/skill_errors.js"; +import { SKILL_BOUNDS } from "../src/core/skills/skill_bounds.js"; +import type { SkillIndex, SkillDescriptor } from "../src/core/skills/skill_types.js"; + +const CANARY = "CANARY-9f3e2d1c-lazy-proof-do-not-index"; + +let configDir: string; +let projectRoot: string; +let builtinRoot: string; + +function manifest(id: string, extra: Record = {}): Record { + return { + schema_version: 1, + id, + version: "1.0.0", + name: "Lazy " + (id.split("/")[1] ?? id), + description: "Fixture skill " + id + " for lazy-loading tests.", + ...extra, + }; +} + +function writeSkill(dirName: string, raw: Record, files: Readonly>): string { + const root = join(projectSkillsRoot(projectRoot), dirName); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "skill.json"), JSON.stringify(raw, null, 2), "utf8"); + for (const [relative, content] of Object.entries(files)) { + writeFileSync(join(root, relative), content); + } + return root; +} + +// Tests share one process (--test-isolation=none), so the env override is set +// and restored around every discovery call, never left global. +function withEnv(fn: () => T): T { + const prev = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = configDir; + try { + return fn(); + } finally { + if (prev === undefined) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = prev; + } +} + +function discover(): SkillIndex { + return withEnv(() => discoverSkills({ projectRoot, builtinRoot })); +} + +function byId(index: SkillIndex, id: string): SkillDescriptor { + const descriptor = index.skills.find((skill) => skill.id === id); + assert.ok(descriptor, "descriptor missing for " + id); + return descriptor; +} + +before(() => { + configDir = mkdtempSync(join(tmpdir(), "aether-lazy-cfg-")); + projectRoot = mkdtempSync(join(tmpdir(), "aether-lazy-proj-")); + builtinRoot = mkdtempSync(join(tmpdir(), "aether-lazy-builtin-")); + + writeSkill("lazy", manifest("project/lazy"), { + "SKILL.md": "# Lazy skill\n\n" + CANARY + "\n", + }); + writeSkill("swap", manifest("project/swap"), { + "SKILL.md": "# original body\n", + }); + writeSkill("binary", manifest("project/binary", { context: { resources: ["data.bin"] } }), { + "SKILL.md": "# binary resource skill\n", + "data.bin": Buffer.from([0x68, 0x69, 0x00, 0x21]), + }); + writeSkill("oversized", manifest("project/oversized", { context: { resources: ["huge.md"] } }), { + "SKILL.md": "# oversized resource skill\n", + "huge.md": Buffer.alloc(SKILL_BOUNDS.maxResourceBytes + 1, 0x62), + }); +}); + +after(() => { + rmSync(configDir, { recursive: true, force: true }); + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(builtinRoot, { recursive: true, force: true }); +}); + +test("discovery retains no instruction body — canary absent from the whole index", () => { + const index = discover(); + const descriptor = byId(index, "project/lazy"); + assert.equal("instructions" in descriptor, false, "descriptor must not carry an instructions field"); + const serialized = JSON.stringify(index); + assert.equal(serialized.includes(CANARY), false, "SKILL.md body leaked into the metadata index"); +}); + +test("loadSkillBody re-reads and returns the canary", () => { + const index = discover(); + const loaded = loadSkillBody(byId(index, "project/lazy"), "explicit"); + assert.ok(loaded.instructions.includes(CANARY)); + assert.equal(loaded.invocation, "explicit"); + assert.equal(loaded.resources.length, 0); + assert.ok(loaded.loadedBytes > 0); +}); + +test("TOCTOU: SKILL.md modified after discovery refuses with skill.resource_changed", () => { + const index = discover(); + const descriptor = byId(index, "project/swap"); + writeFileSync(join(descriptor.root, "SKILL.md"), "# swapped after indexing\n", "utf8"); + try { + loadSkillBody(descriptor, "explicit"); + assert.fail("expected refusal"); + } catch (error) { + assert.ok(error instanceof SkillError); + assert.equal(error.code, "skill.resource_changed"); + } + // restore so later discoveries in this file stay clean + writeFileSync(join(descriptor.root, "SKILL.md"), "# original body\n", "utf8"); +}); + +test("oversized declared resource is refused at discovery (digest cap)", () => { + const index = discover(); + assert.equal(index.skills.some((skill) => skill.id === "project/oversized"), false); + assert.ok( + index.errors.some( + (error) => error.root.endsWith("oversized") && error.errors.some((message) => /exceeds \d+ bytes: huge\.md/.test(message)), + ), + "oversized resource must surface as an index error", + ); +}); + +test("binary declared resource loads at discovery but refuses in loadSkillBody with skill.resource_unsafe", () => { + const index = discover(); + const descriptor = byId(index, "project/binary"); + try { + loadSkillBody(descriptor, "explicit"); + assert.fail("expected refusal"); + } catch (error) { + assert.ok(error instanceof SkillError); + assert.equal(error.code, "skill.resource_unsafe"); + assert.match(error.refusal.detail, /binary resource not supported: data\.bin/); + } +}); diff --git a/test/skill_performance.test.ts b/test/skill_performance.test.ts new file mode 100644 index 0000000..a051f56 --- /dev/null +++ b/test/skill_performance.test.ts @@ -0,0 +1,80 @@ +// Loop F — lazy-loading and performance bounds. +// Discovery over many skills stays linear and never opens non-selected bodies. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverSkills } from "../src/core/skills/skill_discovery.js"; +import { resolveAutomatic } from "../src/core/skills/skill_resolver.js"; +import { SKILL_BOUNDS } from "../src/core/skills/skill_bounds.js"; + +function withEnv(key: string, value: string, fn: () => T): T { + const prior = process.env[key]; + process.env[key] = value; + try { + return fn(); + } finally { + if (prior == null) delete process.env[key]; + else process.env[key] = prior; + } +} + +function seedProject(count: number): string { + const root = mkdtempSync(join(tmpdir(), "aether-perf-")); + for (let index = 0; index < count; index++) { + const name = "skill-" + String(index).padStart(4, "0"); + const dir = join(root, ".aether", "skills", "project", name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "skill.json"), JSON.stringify({ + schema_version: 1, + id: "project/" + name, + version: "1.0.0", + name: "Skill " + index, + description: "perf fixture " + index, + entrypoint: "SKILL.md", + triggers: { commands: [], phrases: ["perf trigger " + index], automatic: true }, + tools: { allowed: ["read_file"], required: [], denied: [] }, + permissions: { requires: ["workspace.read"], may_request: [], forbids: [] }, + context: { max_tokens: 100, resources: [] }, + outputs: { kinds: [], verification: [] }, + dependencies: { skills: [] }, + compatibility: { min_agent_version: "0.1.0", capability_contract: 1 }, + health: { eval_manifest: null }, + })); + writeFileSync(join(dir, "SKILL.md"), "BODY-CANARY-" + index + "\n"); + } + return root; +} + +test("indexing 200 skills stays bounded and retains no body text", () => { + const root = seedProject(200); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const emptyBuiltin = mkdtempSync(join(tmpdir(), "aether-bi-")); + const started = process.hrtime.bigint(); + const index = discoverSkills({ projectRoot: root, builtinRoot: emptyBuiltin }); + const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6; + assert.equal(index.skills.length, 200); + // Generous CI bound: linear digest walk over 200 tiny skills. A quadratic + // regression (directory rescan per skill) blows far past this. + assert.ok(elapsedMs < 10_000, "indexing took " + elapsedMs.toFixed(0) + "ms"); + const serialized = JSON.stringify(index); + assert.ok(!serialized.includes("BODY-CANARY-"), "index retained skill body text"); + }); +}); + +test("automatic selection over a large index respects the candidate cap", () => { + const root = seedProject(50); + withEnv("AETHER_CONFIG_DIR", mkdtempSync(join(tmpdir(), "aether-cfg-")), () => { + const emptyBuiltin = mkdtempSync(join(tmpdir(), "aether-bi-")); + const index = discoverSkills({ projectRoot: root, builtinRoot: emptyBuiltin }); + // Project skills are untrusted by default → automatic selection yields + // NOTHING even though every manifest says automatic: true. Trust is the + // gate; the cap only applies after it. + const prompt = "perf trigger 0 perf trigger 1 perf trigger 2 perf trigger 3 perf trigger 4"; + const matches = resolveAutomatic(index, prompt); + assert.equal(matches.length, 0); + assert.ok(SKILL_BOUNDS.maxAutomaticSkillsPerTurn <= 3); + }); +}); diff --git a/test/skill_policy_packet.test.ts b/test/skill_policy_packet.test.ts new file mode 100644 index 0000000..534ebfa --- /dev/null +++ b/test/skill_policy_packet.test.ts @@ -0,0 +1,199 @@ +// Effective policy intersection, per-call refusal gate, and the bounded +// sanitized skill context packet. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { TOOLS } from "../src/core/brain_protocol.js"; +import { + calculateSkillPolicy, + refuseUndeclaredToolCall, + assertRequiredPermissions, + type PermissionEnvelope, +} from "../src/core/skills/skill_policy.js"; +import { + buildSkillContextPacket, + sanitizeForTransport, + approximateTokens, + SKILL_CONTEXT_CONTRACT_VERSION, +} from "../src/core/skills/context_packet.js"; +import { SkillError } from "../src/core/skills/skill_errors.js"; +import { SKILL_BOUNDS } from "../src/core/skills/skill_bounds.js"; +import { validateToolPermissionCoverage, type PermissionName } from "../src/core/skills/permission_vocabulary.js"; +import { validateSkillManifest } from "../src/core/skills/skill_schema.js"; +import type { LoadedSkill, SkillDescriptor, SkillPolicy } from "../src/core/skills/skill_types.js"; + +function makeLoadedSkill(options: { + id?: string; + allowed?: readonly string[]; + requires?: readonly string[]; + forbids?: readonly string[]; + instructions?: string; + loadedBytes?: number; +} = {}): LoadedSkill { + const id = options.id ?? "user/policy-demo"; + const validation = validateSkillManifest( + { + schema_version: 1, + id, + version: "1.0.0", + name: "Policy Demo", + description: "Fixture skill for policy and packet tests.", + tools: { allowed: options.allowed ?? ["read_file"], required: [], denied: [] }, + permissions: { + requires: options.requires ?? [], + may_request: [], + forbids: options.forbids ?? [], + }, + }, + "user", + ); + assert.equal(validation.ok, true, JSON.stringify(validation.ok ? [] : validation.errors)); + if (!validation.ok) throw new Error("unreachable"); + const descriptor: SkillDescriptor = { + id, + version: "1.0.0", + name: "Policy Demo", + description: "Fixture skill for policy and packet tests.", + scope: "user", + root: "/virtual/" + id, + sha256: "d".repeat(64), + trust: "trusted", + enabled: true, + automatic: false, + approxTokens: 100, + manifest: validation.manifest, + }; + const instructions = options.instructions ?? "# do the thing\n"; + return { + descriptor, + invocation: "explicit", + instructions, + resources: [{ name: "notes.md", sha256: "e".repeat(64), content: "note body\n" }], + loadedBytes: options.loadedBytes ?? instructions.length + 10, + }; +} + +function envelope(...names: PermissionName[]): PermissionEnvelope { + return new Set(names); +} + +test("calculateSkillPolicy drops tools whose permission the skill forbids", () => { + const skill = makeLoadedSkill({ + allowed: ["read_file", "web_fetch", "web_search"], + forbids: ["network.general"], + }); + const policy = calculateSkillPolicy(skill); + assert.deepEqual(policy.allowedTools, ["read_file"]); + assert.equal(policy.skillId, "user/policy-demo"); + assert.deepEqual(policy.forbiddenPermissions, ["network.general"]); +}); + +test("refuseUndeclaredToolCall: unknown tool", () => { + const refusal = refuseUndeclaredToolCall("teleport", [], envelope()); + assert.ok(refusal); + assert.equal(refusal.code, "skill.tool_not_declared"); + assert.match(refusal.detail, /unknown tool: teleport/); +}); + +test("refuseUndeclaredToolCall: undeclared tool includes effective_allowed_tools", () => { + const policy = calculateSkillPolicy(makeLoadedSkill({ allowed: ["read_file"] })); + const refusal = refuseUndeclaredToolCall("write_file", [policy], envelope("workspace.write")); + assert.ok(refusal); + assert.equal(refusal.code, "skill.tool_not_declared"); + assert.equal(refusal.skillId, "user/policy-demo"); + assert.deepEqual(refusal.context?.["effective_allowed_tools"], ["read_file"]); +}); + +test("refuseUndeclaredToolCall: forbidden permission wins even when tool is listed", () => { + // Handcrafted policy: allowedTools still contains the tool, so the forbidden + // permission branch (not the undeclared branch) must fire. + const policy: SkillPolicy = { + skillId: "user/handmade", + allowedTools: ["web_fetch"], + requiredPermissions: [], + forbiddenPermissions: ["network.general"], + }; + const refusal = refuseUndeclaredToolCall("web_fetch", [policy], envelope("network.general")); + assert.ok(refusal); + assert.equal(refusal.code, "skill.permission_denied"); + assert.equal(refusal.context?.["permission"], "network.general"); +}); + +test("refuseUndeclaredToolCall: missing envelope permission", () => { + const policy = calculateSkillPolicy(makeLoadedSkill({ allowed: ["read_file"] })); + const refusal = refuseUndeclaredToolCall("read_file", [policy], envelope("workspace.write")); + assert.ok(refusal); + assert.equal(refusal.code, "skill.permission_unavailable"); + assert.equal(refusal.context?.["permission"], "workspace.read"); +}); + +test("refuseUndeclaredToolCall: null when declared and permitted", () => { + const policy = calculateSkillPolicy(makeLoadedSkill({ allowed: ["read_file"] })); + assert.equal(refuseUndeclaredToolCall("read_file", [policy], envelope("workspace.read")), null); +}); + +test("assertRequiredPermissions throws skill.permission_unavailable", () => { + const policy = calculateSkillPolicy(makeLoadedSkill({ allowed: ["git_commit"], requires: ["git.commit"] })); + assert.equal(undefined, assertRequiredPermissions(policy, envelope("git.commit"))); + try { + assertRequiredPermissions(policy, envelope("workspace.read")); + assert.fail("expected refusal"); + } catch (error) { + assert.ok(error instanceof SkillError); + assert.equal(error.code, "skill.permission_unavailable"); + assert.equal(error.refusal.context?.["permission"], "git.commit"); + } +}); + +test("buildSkillContextPacket produces the versioned, digest-prefixed shape", () => { + const packet = buildSkillContextPacket([makeLoadedSkill()]); + assert.equal(packet.contract_version, SKILL_CONTEXT_CONTRACT_VERSION); + assert.equal(packet.contract_version, 1); + assert.equal(packet.skills.length, 1); + const entry = packet.skills[0]; + assert.ok(entry); + assert.equal(entry.id, "user/policy-demo"); + assert.equal(entry.digest, "sha256:" + "d".repeat(64)); + assert.equal(entry.invocation, "explicit"); + assert.equal(entry.scope, "user"); + const resource = entry.resources[0]; + assert.ok(resource); + assert.equal(resource.digest, "sha256:" + "e".repeat(64)); + assert.deepEqual(entry.tool_policy.allowed, ["read_file"]); +}); + +test("skill count over maxSkillsPerTurn refuses with skill.context_budget_exceeded", () => { + const skills = Array.from({ length: SKILL_BOUNDS.maxSkillsPerTurn + 1 }, (_, index) => + makeLoadedSkill({ id: "user/many-" + index }), + ); + try { + buildSkillContextPacket(skills); + assert.fail("expected refusal"); + } catch (error) { + assert.ok(error instanceof SkillError); + assert.equal(error.code, "skill.context_budget_exceeded"); + } +}); + +test("token budget overflow refuses with skill.context_budget_exceeded", () => { + const overBudgetBytes = (SKILL_BOUNDS.maxLoadedSkillTokens + 1) * 4; + assert.ok(approximateTokens(overBudgetBytes) > SKILL_BOUNDS.maxLoadedSkillTokens); + try { + buildSkillContextPacket([makeLoadedSkill({ loadedBytes: overBudgetBytes })]); + assert.fail("expected refusal"); + } catch (error) { + assert.ok(error instanceof SkillError); + assert.equal(error.code, "skill.context_budget_exceeded"); + assert.match(error.refusal.detail, /budget/); + } +}); + +test("sanitizeForTransport strips NUL and ESC but keeps newline and tab", () => { + assert.equal(sanitizeForTransport("a\u0000b\u001bc\nd\te\u007ff"), "abc\nd\tef"); + assert.equal(sanitizeForTransport("clean text\n"), "clean text\n"); +}); + +test("validateToolPermissionCoverage over the frozen tool set returns no findings", () => { + assert.deepEqual(validateToolPermissionCoverage(TOOLS), []); + assert.notDeepEqual(validateToolPermissionCoverage([...TOOLS, "extra_tool"]), []); +}); diff --git a/test/skill_schema.test.ts b/test/skill_schema.test.ts new file mode 100644 index 0000000..94126eb --- /dev/null +++ b/test/skill_schema.test.ts @@ -0,0 +1,219 @@ +// aether.skill/v1 manifest validation — every invalid class is a hard, +// actionable error; valid manifests normalize deterministically. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + validateSkillManifest, + compareSemver, + isSafeRelativePath, + SKILL_SCHEMA_VERSION, + type SkillScope, +} from "../src/core/skills/skill_schema.js"; + +function fullManifest(): Record { + return { + schema_version: SKILL_SCHEMA_VERSION, + id: "project/review-pr", + version: "1.2.3", + name: "Review PR", + description: "Reviews a pull request against repo conventions.", + entrypoint: "SKILL.md", + triggers: { + commands: ["review-pr"], + phrases: ["review this pr"], + automatic: true, + }, + tools: { + allowed: ["read_file", "repo_search", "web_fetch"], + required: ["read_file"], + denied: ["run_shell"], + }, + permissions: { + requires: ["workspace.read"], + may_request: ["network.general"], + forbids: ["git.push"], + }, + context: { + max_tokens: 2000, + resources: ["references/checklist.md"], + }, + outputs: { + kinds: ["review"], + verification: ["typecheck passes"], + }, + dependencies: { skills: ["project/style-guide"] }, + compatibility: { min_agent_version: "0.2.0", capability_contract: 1 }, + health: { eval_manifest: "evals/manifest.json" }, + }; +} + +function expectErrors(raw: unknown, scope: SkillScope, pattern: RegExp): void { + const result = validateSkillManifest(raw, scope); + assert.equal(result.ok, false, "expected validation failure"); + if (result.ok) return; + assert.ok( + result.errors.some((error) => pattern.test(error)), + "no error matched " + pattern + " in: " + JSON.stringify(result.errors), + ); +} + +test("valid full manifest passes and normalizes", () => { + const result = validateSkillManifest(fullManifest(), "project"); + assert.equal(result.ok, true, JSON.stringify(result.ok ? [] : result.errors)); + if (!result.ok) return; + const manifest = result.manifest; + assert.equal(manifest.schemaVersion, SKILL_SCHEMA_VERSION); + assert.equal(manifest.id, "project/review-pr"); + assert.equal(manifest.version, "1.2.3"); + assert.equal(manifest.entrypoint, "SKILL.md"); + assert.deepEqual(manifest.triggers, { + commands: ["review-pr"], + phrases: ["review this pr"], + automatic: true, + }); + assert.deepEqual(manifest.tools, { + allowed: ["read_file", "repo_search", "web_fetch"], + required: ["read_file"], + denied: ["run_shell"], + }); + assert.deepEqual(manifest.permissions, { + requires: ["workspace.read"], + mayRequest: ["network.general"], + forbids: ["git.push"], + }); + assert.equal(manifest.context.maxTokens, 2000); + assert.deepEqual(manifest.context.resources, ["references/checklist.md"]); + assert.deepEqual(manifest.dependencies.skills, ["project/style-guide"]); + assert.equal(manifest.compatibility.minAgentVersion, "0.2.0"); + assert.equal(manifest.health.evalManifest, "evals/manifest.json"); +}); + +test("minimal manifest gets documented defaults", () => { + const result = validateSkillManifest( + { + schema_version: SKILL_SCHEMA_VERSION, + id: "user/tiny", + version: "0.1.0", + name: "Tiny", + description: "Smallest valid skill.", + }, + "user", + ); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.manifest.entrypoint, "SKILL.md"); + assert.equal(result.manifest.triggers.automatic, false); + assert.deepEqual(result.manifest.tools.allowed, []); + assert.equal(result.manifest.context.maxTokens, 4000); + assert.equal(result.manifest.health.evalManifest, null); +}); + +test("non-object manifest rejected", () => { + expectErrors([], "project", /must be a JSON object/); + expectErrors("nope", "project", /must be a JSON object/); +}); + +test("unknown top-level key rejected", () => { + expectErrors({ ...fullManifest(), surprise: 1 }, "project", /unknown key: surprise/); +}); + +test("unsupported schema_version mentions migration path", () => { + const result = validateSkillManifest({ ...fullManifest(), schema_version: 99 }, "project"); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.errors.length, 1); + const message = result.errors[0] ?? ""; + assert.match(message, /unsupported schema_version 99/); + assert.match(message, /upgrade the agent or re-author/); + assert.match(message, new RegExp("aether\\.skill/v" + SKILL_SCHEMA_VERSION)); +}); + +test("bad id casing rejected", () => { + expectErrors({ ...fullManifest(), id: "Project/Review-PR" }, "project", /id must match/); +}); + +test("id scope mismatch rejected", () => { + expectErrors({ ...fullManifest(), id: "user/review-pr" }, "project", /does not match discovery scope 'project'/); +}); + +test("aether/* namespace rejected outside builtin scope", () => { + expectErrors({ ...fullManifest(), id: "aether/review-pr" }, "project", /reserved for signed built-in skills/); +}); + +test("non-semver version rejected", () => { + expectErrors({ ...fullManifest(), version: "1.2" }, "project", /strict semver/); + expectErrors({ ...fullManifest(), version: "v1.2.3" }, "project", /strict semver/); + expectErrors({ ...fullManifest(), version: "1.02.3" }, "project", /strict semver/); +}); + +test("tools.required must be subset of tools.allowed", () => { + const raw = fullManifest(); + raw["tools"] = { allowed: ["read_file"], required: ["write_file"], denied: [] }; + expectErrors(raw, "project", /required must be a subset of tools\.allowed: write_file/); +}); + +test("tools.denied must not intersect tools.allowed", () => { + const raw = fullManifest(); + raw["tools"] = { allowed: ["read_file"], required: [], denied: ["read_file"] }; + expectErrors(raw, "project", /denied must not intersect tools\.allowed: read_file/); +}); + +test("unknown tool name rejected", () => { + const raw = fullManifest(); + raw["tools"] = { allowed: ["teleport"], required: [], denied: [] }; + expectErrors(raw, "project", /unknown tool name: teleport/); +}); + +test("unknown permission name rejected", () => { + const raw = fullManifest(); + raw["permissions"] = { requires: ["universe.admin"], may_request: [], forbids: [] }; + expectErrors(raw, "project", /unknown permission name: universe\.admin/); +}); + +test("undeclarable permission secrets.read rejected in requires", () => { + const raw = fullManifest(); + raw["permissions"] = { requires: ["secrets.read"], may_request: [], forbids: [] }; + expectErrors(raw, "project", /'secrets\.read' cannot be declared by a skill/); +}); + +test("permission both requested and forbidden rejected", () => { + const raw = fullManifest(); + raw["permissions"] = { requires: ["workspace.read"], may_request: [], forbids: ["workspace.read"] }; + expectErrors(raw, "project", /'workspace\.read' is both requested and forbidden/); +}); + +test("absolute, traversal, and URL resource paths rejected", () => { + for (const bad of ["/etc/passwd", "C:/windows/win.ini", "../outside.md", "docs/../../escape.md", "https://evil.example/x.md", "file:x"]) { + const raw = fullManifest(); + raw["context"] = { resources: [bad] }; + expectErrors(raw, "project", /safe relative path/); + } + assert.equal(isSafeRelativePath("docs/notes.md"), true); + assert.equal(isSafeRelativePath("docs\\notes.md"), false); + assert.equal(isSafeRelativePath(""), false); +}); + +test("oversized description rejected", () => { + expectErrors({ ...fullManifest(), description: "x".repeat(1025) }, "project", /description is required, at most/); +}); + +test("duplicate list entries rejected", () => { + const raw = fullManifest(); + raw["tools"] = { allowed: ["read_file", "read_file"], required: [], denied: [] }; + expectErrors(raw, "project", /duplicate entry: read_file/); +}); + +test("self-dependency rejected", () => { + const raw = fullManifest(); + raw["dependencies"] = { skills: ["project/review-pr"] }; + expectErrors(raw, "project", /cannot depend on itself/); +}); + +test("compareSemver orders strictly", () => { + assert.ok(compareSemver("1.0.0", "1.0.1") < 0); + assert.ok(compareSemver("1.0.9", "1.1.0") < 0); + assert.ok(compareSemver("1.9.0", "1.10.0") < 0); + assert.ok(compareSemver("2.0.0", "1.99.99") > 0); + assert.equal(compareSemver("1.2.3", "1.2.3"), 0); +}); diff --git a/test/skill_trust_lock.test.ts b/test/skill_trust_lock.test.ts new file mode 100644 index 0000000..2aacd6b --- /dev/null +++ b/test/skill_trust_lock.test.ts @@ -0,0 +1,198 @@ +// skills.lock.json round-trip + drift, and the local digest-bound trust store. + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readSkillLock, + writeSkillLock, + compareLock, + projectLockPath, + userLockPath, + type SkillLockEntry, +} from "../src/core/skills/skill_lock.js"; +import { + loadTrustStore, + lookupTrust, + recordTrust, + removeTrust, + trustStorePath, + type SkillTrustRecord, +} from "../src/core/skills/skill_trust.js"; + +let dir: string; + +before(() => { + dir = mkdtempSync(join(tmpdir(), "aether-trustlock-")); +}); + +after(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +// Tests share one process (--test-isolation=none), so the env override is set +// and restored around every call that touches configDir(), never left global. +function withEnv(fn: () => T): T { + const prev = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = dir; + try { + return fn(); + } finally { + if (prev === undefined) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = prev; + } +} + +const SHA_A = "a".repeat(64); +const SHA_B = "b".repeat(64); +const SHA_C = "c".repeat(64); + +function lockEntry(id: string, sha256: string): SkillLockEntry { + return { id, version: "1.0.0", source: ".aether/skills/project/" + id.split("/")[1], sha256, dependencies: [] }; +} + +test("lock paths derive from project root and config dir", () => { + assert.equal(projectLockPath(join(dir, "proj")), join(dir, "proj", ".aether", "skills.lock.json")); + withEnv(() => { + assert.equal(userLockPath(), join(dir, "skills.lock.json")); + assert.equal(trustStorePath(), join(dir, "skill-trust.json")); + }); +}); + +test("writeSkillLock then readSkillLock round-trips sorted", () => { + const path = join(dir, "roundtrip", "skills.lock.json"); + writeSkillLock(path, [lockEntry("project/zeta", SHA_B), lockEntry("project/alpha", SHA_A)]); + const result = readSkillLock(path); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual( + result.lock.skills.map((entry) => entry.id), + ["project/alpha", "project/zeta"], + ); + const first = result.lock.skills[0]; + assert.ok(first); + assert.equal(first.sha256, SHA_A); + assert.equal(first.version, "1.0.0"); +}); + +test("missing lock reports missing=true", () => { + const result = readSkillLock(join(dir, "nowhere", "skills.lock.json")); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.missing, true); +}); + +test("corrupt JSON lock is an error, not missing", () => { + const path = join(dir, "corrupt.lock.json"); + writeFileSync(path, "{ not json", "utf8"); + const result = readSkillLock(path); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.missing, false); + assert.match(result.error, /not valid JSON/); +}); + +test("lock entry with bad sha256 rejected", () => { + const path = join(dir, "badsha.lock.json"); + writeFileSync( + path, + JSON.stringify({ + schema_version: 1, + skills: [{ id: "project/x", version: "1.0.0", source: "s", sha256: "abc123", dependencies: [] }], + }), + "utf8", + ); + const result = readSkillLock(path); + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /sha256 must be 64 hex chars/); +}); + +test("unsupported lock schema_version rejected", () => { + const path = join(dir, "badver.lock.json"); + writeFileSync(path, JSON.stringify({ schema_version: 9, skills: [] }), "utf8"); + const result = readSkillLock(path); + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /unsupported lock schema_version/); +}); + +test("compareLock classifies unlocked, missing, changed", () => { + const path = join(dir, "drift.lock.json"); + writeSkillLock(path, [lockEntry("project/kept", SHA_A), lockEntry("project/gone", SHA_B)]); + const read = readSkillLock(path); + assert.equal(read.ok, true); + if (!read.ok) return; + const discovered = new Map([ + ["project/kept", SHA_C], // digest drifted + ["project/new", SHA_A], // never locked + ]); + const drift = compareLock(read.lock, discovered); + assert.deepEqual(drift.unlocked, ["project/new"]); + assert.deepEqual(drift.missing, ["project/gone"]); + assert.deepEqual(drift.changed, ["project/kept"]); +}); + +function trustRecord(overrides: Partial = {}): SkillTrustRecord { + return { + projectRoot: join(dir, "proj"), + repository: null, + skillId: "project/alpha", + version: "1.0.0", + sha256: SHA_A, + trustedAt: new Date("2026-08-14T00:00:00Z").toISOString(), + method: "inspect", + requestedPermissions: ["workspace.read"], + ...overrides, + }; +} + +test("recordTrust then lookupTrust with same digest is trusted", () => { + withEnv(() => { + recordTrust(trustRecord()); + const lookup = lookupTrust(loadTrustStore(), join(dir, "proj"), "project/alpha", SHA_A); + assert.equal(lookup.state, "trusted"); + if (lookup.state === "trusted") assert.equal(lookup.record.method, "inspect"); + }); +}); + +test("different digest reports changed, never trusted", () => { + withEnv(() => { + recordTrust(trustRecord()); + const lookup = lookupTrust(loadTrustStore(), join(dir, "proj"), "project/alpha", SHA_B); + assert.equal(lookup.state, "changed"); + if (lookup.state === "changed") assert.equal(lookup.record.sha256, SHA_A); + }); +}); + +test("no record at all is untrusted", () => { + withEnv(() => { + const lookup = lookupTrust(loadTrustStore(), join(dir, "proj"), "project/unknown", SHA_A); + assert.equal(lookup.state, "untrusted"); + }); +}); + +test("recordTrust replaces the prior record for the same (projectRoot, skillId)", () => { + withEnv(() => { + recordTrust(trustRecord({ sha256: SHA_A })); + recordTrust(trustRecord({ sha256: SHA_B, version: "1.1.0" })); + const store = loadTrustStore(); + const matches = store.records.filter( + (record) => record.projectRoot === join(dir, "proj") && record.skillId === "project/alpha", + ); + assert.equal(matches.length, 1, "one live record per (projectRoot, skillId)"); + assert.equal(lookupTrust(store, join(dir, "proj"), "project/alpha", SHA_B).state, "trusted"); + assert.equal(lookupTrust(store, join(dir, "proj"), "project/alpha", SHA_A).state, "changed"); + }); +}); + +test("removeTrust deletes the record and reports whether one existed", () => { + withEnv(() => { + recordTrust(trustRecord()); + assert.equal(removeTrust(join(dir, "proj"), "project/alpha"), true); + assert.equal(lookupTrust(loadTrustStore(), join(dir, "proj"), "project/alpha", SHA_A).state, "untrusted"); + assert.equal(removeTrust(join(dir, "proj"), "project/alpha"), false); + }); +}); From 152119937cf336062b33f50f1e368e299e8bbcae Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 05:51:27 -0400 Subject: [PATCH 02/11] fix(instructions): escape every glob metacharacter, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL js/incomplete-sanitization (high) on PR #72, at src/core/instructions/instruction_resolver.ts:18 — the metacharacter escape in globToRegExp used a non-global regex. Not exploitable as written: `char` is `glob[index]`, always a single code unit, so there is never a second occurrence to miss. But the safety of that escape rests on an invariant nothing in the function states, and a future change to a multi-character token would silently widen every glob's match set. Fixed rather than dismissed, since the fix is free. Adds a regression test that pins the property rather than the implementation: a glob containing . + ( ) | { } [ ] $ ^ must match itself literally and must not match a decoy path, while ** and * keep working. Mutation-checked: replacing the escape with a bare `pattern += char` fails the new test with "unescaped . would match axts"; restoring it passes. Gates at this commit: npm run typecheck exit 0 npm test 1007 pass / 0 fail --- src/core/instructions/instruction_resolver.ts | 5 ++- test/instruction_resolver.test.ts | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/core/instructions/instruction_resolver.ts b/src/core/instructions/instruction_resolver.ts index ff7600f..9b02348 100644 --- a/src/core/instructions/instruction_resolver.ts +++ b/src/core/instructions/instruction_resolver.ts @@ -15,7 +15,10 @@ function globToRegExp(glob: string): RegExp { } else if (char === "?") { pattern += "[^/]"; } else if (char != null) { - pattern += char.replace(/[.+^${}()|[\]\\]/, "\\$&"); + // The global flag is not load-bearing today — `char` is a single code unit, + // so there is never a second occurrence to miss — but the escape must not + // silently depend on that invariant if this ever takes a longer token. + pattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&"); } } return new RegExp("^" + pattern + "$"); diff --git a/test/instruction_resolver.test.ts b/test/instruction_resolver.test.ts index a71a2e0..1d1b207 100644 --- a/test/instruction_resolver.test.ts +++ b/test/instruction_resolver.test.ts @@ -13,6 +13,7 @@ import { sourceAppliesTo, } from "../src/core/instructions/instruction_resolver.js"; import { SKILL_BOUNDS } from "../src/core/skills/skill_bounds.js"; +import type { InstructionSource } from "../src/core/instructions/instruction_types.js"; function withEnv(key: string, value: string, fn: () => T): T { const prior = process.env[key]; @@ -190,3 +191,47 @@ test("detectConflicts keeps highest-precedence command as effective", () => { assert.equal(conflicts[0]?.effective, "npm test"); }); }); + +// Regression: CodeQL js/incomplete-sanitization on globToRegExp's escape. +// The escape must treat every regex metacharacter in a glob as a literal, so a +// glob can never widen its own match set. Dropping the escape (or its global +// flag, once a token is longer than one character) makes these assertions fail. +test("glob metacharacters are escaped, never interpreted as regex", () => { + const ruleWith = (globs: readonly string[]): InstructionSource => ({ + kind: "cursor-rule", + path: "/p/.cursor/rules/x.mdc", + displayPath: ".cursor/rules/x.mdc", + scopeDir: "", + globs, + sha256: "0".repeat(64), + sizeBytes: 0, + content: "", + parseStatus: "ok", + warnings: [], + }); + + // A dot is a literal dot, not "any character". + const dot = ruleWith(["a.ts"]); + assert.equal(sourceAppliesTo(dot, "a.ts"), true); + assert.equal(sourceAppliesTo(dot, "axts"), false, "unescaped . would match axts"); + + // Anchors, groups, alternation and quantifiers are literals too. + for (const [glob, literal, decoy] of [ + ["a+.ts", "a+.ts", "aa.ts"], + ["a(b).ts", "a(b).ts", "ab.ts"], + ["a|b.ts", "a|b.ts", "a.ts"], + ["a{2}.ts", "a{2}.ts", "aa.ts"], + ["a[b].ts", "a[b].ts", "ab.ts"], + ["a$b.ts", "a$b.ts", "ab.ts"], + ["a^b.ts", "a^b.ts", "ab.ts"], + ] as const) { + const rule = ruleWith([glob]); + assert.equal(sourceAppliesTo(rule, literal), true, `${glob} should match itself`); + assert.equal(sourceAppliesTo(rule, decoy), false, `${glob} must not match ${decoy}`); + } + + // The supported wildcards still work — escaping must not disarm them. + const star = ruleWith(["src/**/*.ts"]); + assert.equal(sourceAppliesTo(star, "src/core/x.ts"), true); + assert.equal(sourceAppliesTo(star, "docs/x.md"), false); +}); From ba7d04179c2d5dd642ce550d11dbef79a7186fd4 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 05:56:25 -0400 Subject: [PATCH 03/11] feat(capabilities): capability matrix and redacted support bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR A0.2 of the SC-A0 Skills & Health integration rescue, part 1 of 2. Recovers PR #71's capability matrix and support bundle onto main: src/core/capabilities.ts capability matrix src/generated/agent_capabilities.ts packaged offline fallback snapshot src/core/support_bundle.ts redacted, self-verifying bundle src/core/redaction.ts shared redaction vocabulary src/core/tar.ts dependency-free tar writer capabilities, redaction, and tar applied unmodified. support_bundle needed a two-line port: it called PR #71's doctorReportV2, which this rescue drops in favour of main's diagnosticReport (see the conflict matrix in _loopstate/LOOP-01/sc-a0-2026-08-19/AUDIT-ARTIFACT.md, decision C1). The swap is a strict improvement for the bundle. PR #71's report modelled configured/reachable/verified as plain booleans, so a fast-mode bundle — which performs no network I/O — had to serialize verified:false for every remote axis, indistinguishable from checked-and-failed. main's HealthReport carries per-axis "not-checked", so an unexercised probe stays visibly unexercised in the artifact a user sends to support. Still owed for A0.2 part 2: porting PR #71's skills and instructions checks (the only two it has that main lacks) from its CheckSpec onto main's CheckOutcome, and the safe-repair reconciliation. Gates at this commit: npm run typecheck exit 0 npm test 1017 pass / 0 fail (1007 before this commit) npm run smoke 3 pass / 3 skip / 1 fail The smoke failure is `cloud turn` HTTP 401 "Invalid or expired session token". Verified pre-existing: the identical failure reproduces at 1521199 with this commit's changes stashed. It is an expired local credential requiring `aether auth login`, not a code regression, and no file in this commit is on the cloud-turn path. Recorded as an operator-owned gate, not a pass. Bundle safety is covered by the ported suites, both passing here: a seeded canary secret is rejected rather than shipped, and an interrupted generation leaves no misleading "complete" artifact behind. --- src/core/capabilities.ts | 126 ++++++++++++ src/core/redaction.ts | 109 +++++++++++ src/core/support_bundle.ts | 293 ++++++++++++++++++++++++++++ src/core/tar.ts | 92 +++++++++ src/generated/agent_capabilities.ts | 239 +++++++++++++++++++++++ test/redaction.test.ts | 80 ++++++++ test/support_bundle.test.ts | 198 +++++++++++++++++++ 7 files changed, 1137 insertions(+) create mode 100644 src/core/capabilities.ts create mode 100644 src/core/redaction.ts create mode 100644 src/core/support_bundle.ts create mode 100644 src/core/tar.ts create mode 100644 src/generated/agent_capabilities.ts create mode 100644 test/redaction.test.ts create mode 100644 test/support_bundle.test.ts diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts new file mode 100644 index 0000000..5874519 --- /dev/null +++ b/src/core/capabilities.ts @@ -0,0 +1,126 @@ +// Capability contract resolution: server manifest when reachable and +// compatible, packaged offline fallback otherwise. Never mixes the two — +// exactly one contract (with visible provenance) is active per process. + +import { + AGENT_CAPABILITIES_FALLBACK, + AGENT_CAPABILITIES_DIGEST, + AGENT_CAPABILITIES_SOURCE, +} from "../generated/agent_capabilities.js"; +import type { ApiClient } from "./transport.js"; + +export const AGENT_CAPABILITIES_PATH = "/agent/capabilities"; + +export interface CapabilityOverlayFeature { + enabled: boolean; + reason?: string; +} + +export interface ResolvedCapabilities { + /** The active contract object (server or fallback — never merged). */ + contract: Record; + digest: string; + source: "server" | "fallback"; + /** Server-only runtime feature overlay; null on the fallback path. */ + overlay: Record | null; + /** Non-fatal notes (offline, version skew) for the UI to render honestly. */ + warnings: readonly string[]; +} + +interface ServerCapabilitiesResponse { + contract?: Record; + digest?: string; + overlay?: { features?: Record }; +} + +export function fallbackCapabilities(warnings: readonly string[] = []): ResolvedCapabilities { + return { + contract: AGENT_CAPABILITIES_FALLBACK as unknown as Record, + digest: AGENT_CAPABILITIES_DIGEST, + source: "fallback", + overlay: null, + warnings, + }; +} + +/** + * Resolve the active contract. Offline or on any server error, the packaged + * fallback serves local help and operation — with a visible warning, never a + * silent downgrade. An incompatible major contract version keeps the fallback + * and says so; it never adopts a vocabulary this build does not understand. + */ +export async function resolveCapabilities(api: ApiClient): Promise { + let response: ServerCapabilitiesResponse; + try { + response = await api.getJson(AGENT_CAPABILITIES_PATH); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return fallbackCapabilities(["server capability manifest unreachable (" + detail + ") — using packaged snapshot"]); + } + const contract = response.contract; + const digest = response.digest; + if (!contract || typeof digest !== "string") { + return fallbackCapabilities(["server capability response malformed — using packaged snapshot"]); + } + const serverVersion = contract["contract_version"]; + const localVersion = (AGENT_CAPABILITIES_FALLBACK as { contract_version: number }).contract_version; + if (typeof serverVersion !== "number" || Math.trunc(serverVersion) !== Math.trunc(localVersion)) { + return fallbackCapabilities([ + "server capability contract v" + String(serverVersion) + " is incompatible with this client (v" + + localVersion + ") — using packaged snapshot; upgrade the agent", + ]); + } + const warnings: string[] = []; + if (digest !== AGENT_CAPABILITIES_DIGEST) { + warnings.push("server contract digest differs from the packaged snapshot (server wins; consider upgrading)"); + } + return { + contract, + digest, + source: "server", + overlay: response.overlay?.features ?? null, + warnings, + }; +} + +export function packagedCapabilitySource(): typeof AGENT_CAPABILITIES_SOURCE { + return AGENT_CAPABILITIES_SOURCE; +} + +/** Render for `aether capabilities` — static support separate from availability. */ +export function renderCapabilities(resolved: ResolvedCapabilities, availableOnly = false): string { + const lines: string[] = []; + lines.push( + "Capability contract v" + String(resolved.contract["contract_version"]) + + " · " + resolved.source + " · sha256:" + resolved.digest.slice(0, 12) + "…", + ); + for (const warning of resolved.warnings) lines.push("! " + warning); + const tools = resolved.contract["tools"]; + if (Array.isArray(tools)) { + lines.push(""); + lines.push("TOOLS (static support)"); + for (const tool of tools) { + const record = tool as Record; + lines.push( + " " + String(record["name"]).padEnd(14) + + String(record["side_effect"]).padEnd(9) + + String(record["permission"]), + ); + } + } + const features = resolved.contract["client_features"]; + if (Array.isArray(features)) { + lines.push(""); + lines.push("FEATURES" + (resolved.overlay ? " (runtime availability from server)" : " (static — server availability unknown offline)")); + for (const feature of features) { + const name = String(feature); + const overlayEntry = resolved.overlay?.[name]; + const state = overlayEntry + ? overlayEntry.enabled ? "available" : "unavailable" + (overlayEntry.reason ? " (" + overlayEntry.reason + ")" : "") + : "supported"; + if (availableOnly && overlayEntry && !overlayEntry.enabled) continue; + lines.push(" " + name.padEnd(20) + state); + } + } + return lines.join("\n") + "\n"; +} diff --git a/src/core/redaction.ts b/src/core/redaction.ts new file mode 100644 index 0000000..db403fd --- /dev/null +++ b/src/core/redaction.ts @@ -0,0 +1,109 @@ +// Canonical redaction: the single home for secret-shaped pattern detection. +// session_log.ts (durable event records) and support_bundle.ts (exported +// bundles) both import from here so a new detector protects every sink at once. +// +// Invariant: findings returned by scanForSecrets() describe the CLASS of the +// match, never the matched text — a scanner that echoes the secret is a leak. + +import { homedir } from "node:os"; + +export const SENSITIVE_KEY = /token|secret|password|authorization|api[_-]?key|private[_-]?key|credential|pat/i; + +/** Inline redaction for short event fields — behavior owned by session_log's + * contract: bearer/key-value scrubbing plus a hard 512-char cap. */ +export function redactInline(value: string): string { + return value + .replace(/(bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]") + .replace(/((?:token|secret|password|api[_-]?key|authorization)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]") + .slice(0, 512); +} + +const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}(?:\.[A-Za-z0-9_-]*)?/g; +const BEARER_PATTERN = /(bearer\s+)[A-Za-z0-9._~+/=-]+/gi; +const USERINFO_URL_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)([^/\s@]+@)/gi; + +function redactJwt(text: string): string { + return text.replace(JWT_PATTERN, "[REDACTED-JWT]"); +} + +function redactUserinfoUrls(text: string): string { + return text.replace(USERINFO_URL_PATTERN, "$1[REDACTED]@"); +} + +/** `"secret_key": ""` / `secret_key=` — hex values of 32+ chars in + * key/value positions whose key matches the sensitive pattern. */ +function redactSecretHexPairs(text: string): string { + return text + .replace(/("([^"\\]{1,64})"\s*:\s*")([0-9a-fA-F]{32,})(")/g, (whole, open: string, key: string, _hex, close: string) => + SENSITIVE_KEY.test(key) ? open + "[REDACTED]" + close : whole, + ) + .replace(/\b([\w-]{1,64})([:=]\s*)([0-9a-fA-F]{32,})\b/g, (whole, key: string, sep: string) => + SENSITIVE_KEY.test(key) ? key + sep + "[REDACTED]" : whole, + ); +} + +/** Replace values of sensitive-named environment variables wherever they occur. */ +export function redactEnvValues(text: string, env: NodeJS.ProcessEnv = process.env): string { + let out = text; + for (const [key, value] of Object.entries(env)) { + if (!value || value.length < 8) continue; + if (!SENSITIVE_KEY.test(key)) continue; + out = out.split(value).join("[REDACTED]"); + } + return out; +} + +/** Replace the user's home directory (raw, JSON-escaped, and slash-normalized + * spellings) with "~" so exported text carries no private absolute paths. */ +export function redactHomeDir(text: string, home: string = homedir()): string { + if (!home) return text; + const spellings = [home.replaceAll("\\", "\\\\"), home, home.replaceAll("\\", "/")]; + let out = text; + for (const spelling of spellings) { + if (spelling) out = out.split(spelling).join("~"); + } + return out; +} + +/** Full pass for exported artifacts: every detector plus path/env scrubbing. */ +export function redactForBundle(text: string, env: NodeJS.ProcessEnv = process.env): string { + let out = text; + out = out.replace(BEARER_PATTERN, "$1[REDACTED]"); + out = out.replace(/((?:token|secret|password|api[_-]?key|authorization)\s*[:=]\s*)[^\s,;"]+/gi, "$1[REDACTED]"); + out = redactSecretHexPairs(out); + out = redactJwt(out); + out = redactUserinfoUrls(out); + out = redactEnvValues(out, env); + out = redactHomeDir(out); + return out; +} + +/** Detector classes only — used to verify a bundle AFTER redaction ran. */ +export function scanForSecrets(text: string, env: NodeJS.ProcessEnv = process.env): string[] { + const findings: string[] = []; + if (new RegExp(JWT_PATTERN.source).test(text)) findings.push("jwt-shaped string"); + if (new RegExp(BEARER_PATTERN.source, "i").test(text)) findings.push("bearer token"); + for (const match of text.matchAll(new RegExp(USERINFO_URL_PATTERN.source, "gi"))) { + if (match[2] !== "[REDACTED]@") { + findings.push("url with userinfo"); + break; + } + } + const hexPair = /("([^"\\]{1,64})"\s*:\s*")([0-9a-fA-F]{32,})(")|\b([\w-]{1,64})([:=]\s*)([0-9a-fA-F]{32,})\b/g; + for (const match of text.matchAll(hexPair)) { + const key = match[2] ?? match[5] ?? ""; + if (SENSITIVE_KEY.test(key)) { + findings.push("hex secret in sensitive key position"); + break; + } + } + for (const [key, value] of Object.entries(env)) { + if (!value || value.length < 8) continue; + if (!SENSITIVE_KEY.test(key)) continue; + if (text.includes(value)) { + findings.push("sensitive environment value"); + break; + } + } + return findings; +} diff --git a/src/core/support_bundle.ts b/src/core/support_bundle.ts new file mode 100644 index 0000000..b22d20a --- /dev/null +++ b/src/core/support_bundle.ts @@ -0,0 +1,293 @@ +// Redacted support bundle — a single .tar of metadata-only diagnostics. +// +// Contents are inventories, digests, and counts. Never included: repo source, +// diffs, prompts, transcripts beyond redacted event metadata lines, tool +// output bodies, instruction/skill text, tokens, env values, absolute +// private paths. +// +// Invariant: the final bundle file only exists after the candidate has been +// reopened, parsed, allowlist-checked, secret-scanned, and hash-verified. +// Any failure deletes the candidate and surfaces the error. + +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { AppContext } from "./context.js"; +import { diagnosticReport, type DiagnosticDependencies } from "./diagnostics.js"; +import { discoverSkills } from "./skills/skill_discovery.js"; +import { resolveInstructionGraph } from "./instructions/instruction_resolver.js"; +import { logsRoot } from "./session_log.js"; +import { redactForBundle, scanForSecrets } from "./redaction.js"; +import { readTar, writeTar, type TarEntry } from "./tar.js"; +import { VERSION } from "../version.js"; + +export const SUPPORT_BUNDLE_SCHEMA_VERSION = 1; +const RECENT_EVENT_LINES = 200; + +export const SUPPORT_BUNDLE_FILES = [ + "support-manifest.json", + "doctor-report.json", + "runtime.json", + "sanitized-config.json", + "skill-inventory.json", + "instruction-inventory.json", + "recent-redacted-events.ndjson", + "README.txt", +] as const; + +export class SupportBundleError extends Error { + constructor(message: string) { + super(message); + this.name = "SupportBundleError"; + } +} + +export interface SupportBundleOptions { + now?: string; + /** Output directory for the final .tar; defaults to ctx.flags.cwd. */ + outDir?: string; + dependencies?: DiagnosticDependencies; + env?: NodeJS.ProcessEnv; + /** Fault-injection seam (tests): runs after the candidate tar is written and + * before finalize — a throw must leave no final bundle file behind. */ + verifyHook?: () => void; +} + +export interface SupportBundleResult { + path: string; + bytes: number; + sha256: string; +} + +function sha256Hex(data: Buffer): string { + return createHash("sha256").update(data).digest("hex"); +} + +function baseUrlHost(baseUrl: string): string { + try { + return new URL(baseUrl).host; + } catch { + return "(invalid base URL)"; + } +} + +function sanitizedConfig(ctx: AppContext): Record { + return { + schema_version: SUPPORT_BUNDLE_SCHEMA_VERSION, + base_url_host: baseUrlHost(ctx.cfg.baseUrl), + default_model_set: ctx.cfg.defaultModel !== "", + permission_mode: ctx.cfg.permissionMode, + auto_apply: ctx.cfg.autoApply, + telemetry: ctx.cfg.telemetry, + default_effort: ctx.cfg.defaultEffort, + backend: ctx.cfg.backend, + }; +} + +function skillInventory(projectRoot: string): Record { + const index = discoverSkills({ projectRoot }); + return { + schema_version: SUPPORT_BUNDLE_SCHEMA_VERSION, + skills: index.skills.map((descriptor) => ({ + id: descriptor.id, + version: descriptor.version, + scope: descriptor.scope, + digest: "sha256:" + descriptor.sha256, + trust: descriptor.trust, + enabled: descriptor.enabled, + automatic: descriptor.automatic, + tools_allowed: descriptor.manifest.tools.allowed, + tools_required: descriptor.manifest.tools.required, + tools_denied: descriptor.manifest.tools.denied, + permissions_requires: descriptor.manifest.permissions.requires, + permissions_may_request: descriptor.manifest.permissions.mayRequest, + permissions_forbids: descriptor.manifest.permissions.forbids, + has_eval_manifest: descriptor.manifest.health.evalManifest != null, + })), + index_error_count: index.errors.length, + }; +} + +function instructionInventory(projectRoot: string): Record { + const graph = resolveInstructionGraph(projectRoot); + return { + schema_version: SUPPORT_BUNDLE_SCHEMA_VERSION, + sources: graph.sources.map((source) => ({ + kind: source.kind, + path: source.displayPath, + scope: source.scopeDir === "" ? "project" : source.scopeDir, + digest: "sha256:" + source.sha256, + size_bytes: source.sizeBytes, + parse_status: source.parseStatus, + warning_count: source.warnings.length, + })), + conflict_count: graph.conflicts.length, + skipped_count: graph.skipped.length, + }; +} + +/** Last N redacted lines of the newest session's events.jsonl ("" when none). */ +function recentRedactedEvents(): string { + const root = logsRoot(); + if (!existsSync(root)) return ""; + let newest: { path: string; mtime: number } | null = null; + let sessions: string[]; + try { + sessions = readdirSync(root); + } catch { + return ""; + } + for (const session of sessions) { + const eventsPath = join(root, session, "events.jsonl"); + try { + const stat = statSync(eventsPath); + if (stat.isFile() && (!newest || stat.mtimeMs > newest.mtime)) { + newest = { path: eventsPath, mtime: stat.mtimeMs }; + } + } catch { + // not a session directory — skip + } + } + if (!newest) return ""; + let raw: string; + try { + raw = readFileSync(newest.path, "utf8"); + } catch { + return ""; + } + const lines = raw.split("\n").filter((line) => line.trim() !== ""); + return lines.slice(-RECENT_EVENT_LINES).join("\n") + (lines.length ? "\n" : ""); +} + +const README_TEXT = + "Aether support bundle\n" + + "=====================\n\n" + + "Metadata-only diagnostics for troubleshooting. Every text entry passed a\n" + + "redaction pass and a secret scan before this archive was finalized.\n\n" + + " support-manifest.json per-file sha256 hashes\n" + + " doctor-report.json fast doctor run (schema v2, no network)\n" + + " runtime.json node/platform/agent versions\n" + + " sanitized-config.json config booleans + backend host (no token)\n" + + " skill-inventory.json skill metadata (no SKILL.md content)\n" + + " instruction-inventory.json instruction file metadata (no text)\n" + + " recent-redacted-events.ndjson last session event metadata, redacted\n\n" + + "Not included: source code, diffs, prompts, transcripts, tool output,\n" + + "instruction or skill text, tokens, environment values, private paths.\n"; + +function timestampSlug(nowIso: string): string { + const date = new Date(nowIso); + const pad = (value: number): string => String(value).padStart(2, "0"); + return ( + date.getUTCFullYear().toString() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate()) + + "-" + pad(date.getUTCHours()) + pad(date.getUTCMinutes()) + pad(date.getUTCSeconds()) + ); +} + +async function collectEntries( + ctx: AppContext, + nowIso: string, + dependencies: DiagnosticDependencies, + env: NodeJS.ProcessEnv, +): Promise { + // Fast mode only: the bundle must never spend, never touch the network, and + // never claim a path was verified. main`s HealthReport carries per-axis + // "not-checked" states, so an unexercised probe stays visibly unexercised. + const doctor = await diagnosticReport(ctx, { dependencies: { ...dependencies, now: nowIso } }); + const sanitize = (text: string): Buffer => Buffer.from(redactForBundle(text, env), "utf8"); + const json = (value: unknown): string => JSON.stringify(value, null, 2) + "\n"; + const projectRoot = resolve(ctx.flags.cwd); + + const body: TarEntry[] = [ + { name: "doctor-report.json", data: sanitize(json(doctor)) }, + { + name: "runtime.json", + data: sanitize( + json({ + schema_version: SUPPORT_BUNDLE_SCHEMA_VERSION, + node: process.versions.node, + platform: process.platform, + arch: process.arch, + agent_version: VERSION, + }), + ), + }, + { name: "sanitized-config.json", data: sanitize(json(sanitizedConfig(ctx))) }, + { name: "skill-inventory.json", data: sanitize(json(skillInventory(projectRoot))) }, + { name: "instruction-inventory.json", data: sanitize(json(instructionInventory(projectRoot))) }, + { name: "recent-redacted-events.ndjson", data: sanitize(recentRedactedEvents()) }, + { name: "README.txt", data: Buffer.from(README_TEXT, "utf8") }, + ]; + + const manifest = { + schema_version: SUPPORT_BUNDLE_SCHEMA_VERSION, + generated_at: nowIso, + files: body.map((entry) => ({ name: entry.name, sha256: sha256Hex(entry.data), bytes: entry.data.length })), + }; + return [{ name: "support-manifest.json", data: Buffer.from(json(manifest), "utf8") }, ...body]; +} + +function verifyCandidate(archive: Buffer, env: NodeJS.ProcessEnv): void { + const entries = readTar(archive); + const names = entries.map((entry) => entry.name).sort(); + const expected = [...SUPPORT_BUNDLE_FILES].sort(); + if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) { + throw new SupportBundleError("bundle entry names differ from the allowlist"); + } + const byName = new Map(entries.map((entry) => [entry.name, entry.data])); + for (const entry of entries) { + const findings = scanForSecrets(entry.data.toString("utf8"), env); + if (findings.length) { + throw new SupportBundleError("secret scan flagged " + entry.name + ": " + findings.join(", ")); + } + } + const manifestRaw = byName.get("support-manifest.json"); + const manifest = JSON.parse(manifestRaw!.toString("utf8")) as { + files: { name: string; sha256: string; bytes: number }[]; + }; + for (const record of manifest.files) { + const data = byName.get(record.name); + if (!data || sha256Hex(data) !== record.sha256 || data.length !== record.bytes) { + throw new SupportBundleError("manifest hash mismatch for " + record.name); + } + } +} + +export async function createSupportBundle( + ctx: AppContext, + options: SupportBundleOptions = {}, +): Promise { + const nowIso = options.now ?? new Date().toISOString(); + const env = options.env ?? process.env; + const outDir = resolve(options.outDir ?? ctx.flags.cwd); + const fileName = "aether-support-" + timestampSlug(nowIso) + ".tar"; + const finalPath = join(outDir, fileName); + + const entries = await collectEntries(ctx, nowIso, options.dependencies ?? {}, env); + const archive = writeTar(entries); + + // Private staging dir: candidate is only promoted after verification. + const stage = join(tmpdir(), "aether-support-" + process.pid + "-" + randomBytes(4).toString("hex")); + mkdirSync(stage, { recursive: true, mode: 0o700 }); + const candidate = join(stage, fileName); + try { + writeFileSync(candidate, archive, { mode: 0o600 }); + options.verifyHook?.(); + verifyCandidate(readFileSync(candidate), env); + try { + renameSync(candidate, finalPath); + } catch { + // Cross-device rename: copy bytes, then remove the candidate. + writeFileSync(finalPath, readFileSync(candidate), { mode: 0o600 }); + } + } catch (error) { + rmSync(stage, { recursive: true, force: true }); + throw error instanceof SupportBundleError + ? error + : new SupportBundleError(error instanceof Error ? error.message : String(error)); + } + rmSync(stage, { recursive: true, force: true }); + + const finalBytes = readFileSync(finalPath); + return { path: finalPath, bytes: finalBytes.length, sha256: sha256Hex(finalBytes) }; +} diff --git a/src/core/tar.ts b/src/core/tar.ts new file mode 100644 index 0000000..143c098 --- /dev/null +++ b/src/core/tar.ts @@ -0,0 +1,92 @@ +// Minimal uncompressed ustar writer/reader — zero-dep support-bundle +// packaging. Deliberately narrow: regular files only, relative names under +// 100 bytes, no symlinks, no prefix field, deterministic headers (uid/gid 0, +// mtime 0, mode 0644). The reader fails closed on anything outside that set. + +const BLOCK = 512; + +export interface TarEntry { + name: string; + data: Buffer; +} + +function octal(value: number, width: number): string { + return value.toString(8).padStart(width - 1, "0") + "\0"; +} + +function writeHeader(name: string, size: number): Buffer { + if (name.length === 0 || name.length > 100) throw new Error("tar entry name must be 1..100 chars"); + if (name.startsWith("/") || name.includes("\\") || name.split("/").some((part) => part === "" || part === "." || part === "..")) { + throw new Error("tar entry name must be a clean relative path: " + name); + } + const header = Buffer.alloc(BLOCK); + header.write(name, 0, 100, "utf8"); + header.write(octal(0o644, 8), 100); + header.write(octal(0, 8), 108); // uid + header.write(octal(0, 8), 116); // gid + header.write(octal(size, 12), 124); + header.write(octal(0, 12), 136); // mtime — deterministic + header.write(" ", 148); // checksum placeholder: 8 spaces + header.write("0", 156); // typeflag: regular file + header.write("ustar\0", 257); + header.write("00", 263); + let sum = 0; + for (const byte of header) sum += byte; + header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148); + return header; +} + +export function writeTar(entries: readonly TarEntry[]): Buffer { + const parts: Buffer[] = []; + for (const entry of entries) { + parts.push(writeHeader(entry.name, entry.data.length)); + parts.push(entry.data); + const remainder = entry.data.length % BLOCK; + if (remainder) parts.push(Buffer.alloc(BLOCK - remainder)); + } + parts.push(Buffer.alloc(BLOCK * 2)); // archive terminator + return Buffer.concat(parts); +} + +function parseOctal(buffer: Buffer, offset: number, length: number): number { + const text = buffer.toString("ascii", offset, offset + length).replace(/[\0 ]+$/g, "").trim(); + if (!/^[0-7]*$/.test(text)) throw new Error("tar header field is not octal"); + return text ? parseInt(text, 8) : 0; +} + +/** Parse an archive produced by writeTar (or equivalent). Throws on symlinks, + * absolute or traversal names, bad checksums, or truncated data. */ +export function readTar(archive: Buffer): TarEntry[] { + const entries: TarEntry[] = []; + let offset = 0; + while (offset + BLOCK <= archive.length) { + const header = archive.subarray(offset, offset + BLOCK); + if (header.every((byte) => byte === 0)) break; + const magic = header.toString("ascii", 257, 262); + if (magic !== "ustar") throw new Error("tar entry missing ustar magic"); + const stored = parseOctal(header, 148, 8); + let sum = 0; + for (let index = 0; index < BLOCK; index++) { + sum += index >= 148 && index < 156 ? 0x20 : header[index]!; + } + if (sum !== stored) throw new Error("tar header checksum mismatch"); + const typeflag = String.fromCharCode(header[156]!); + if (typeflag !== "0" && typeflag !== "\0") throw new Error("tar entry is not a regular file"); + const nameEnd = header.indexOf(0); + const name = header.toString("utf8", 0, nameEnd < 0 || nameEnd > 100 ? 100 : nameEnd); + if ( + !name || + name.startsWith("/") || + name.includes("\\") || + name.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + throw new Error("tar entry name rejected: " + name); + } + const size = parseOctal(header, 124, 12); + const dataStart = offset + BLOCK; + if (dataStart + size > archive.length) throw new Error("tar entry truncated: " + name); + entries.push({ name, data: Buffer.from(archive.subarray(dataStart, dataStart + size)) }); + offset = dataStart + size + (size % BLOCK ? BLOCK - (size % BLOCK) : 0); + } + return entries; +} diff --git a/src/generated/agent_capabilities.ts b/src/generated/agent_capabilities.ts new file mode 100644 index 0000000..119118c --- /dev/null +++ b/src/generated/agent_capabilities.ts @@ -0,0 +1,239 @@ +// GENERATED — do not edit by hand. +// Source: AetherAI3/AETHER-CLOUD contracts/agent-capabilities.v1.json +// Source commit: 97eacd3e9aca4df226cae638f8f8868b8219fe88 +// Contract version: 1 +// Canonical sha256: 8da094234a370a28dfd6206f039425f086307aa9ca0a67bc004d3d453716ac04 +// Regenerate: node scratchpad gen_fallback.mjs (see docs/releases/skills-health notes) + +/** Offline fallback snapshot of the canonical agent capability contract. */ +export const AGENT_CAPABILITIES_FALLBACK = { + "contract_version": 1, + "dev_session_protocol_versions": [ + 1 + ], + "tools": [ + { + "name": "read_file", + "schema_version": 1, + "side_effect": "read", + "permission": "workspace.read", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + }, + { + "name": "repo_search", + "schema_version": 1, + "side_effect": "read", + "permission": "workspace.read", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + }, + { + "name": "write_file", + "schema_version": 1, + "side_effect": "write", + "permission": "workspace.write", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + }, + { + "name": "run_shell", + "schema_version": 1, + "side_effect": "shell", + "permission": "shell.execute", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + }, + { + "name": "run_tests", + "schema_version": 1, + "side_effect": "shell", + "permission": "shell.test", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + }, + { + "name": "git_commit", + "schema_version": 1, + "side_effect": "git", + "permission": "git.commit", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + }, + { + "name": "web_search", + "schema_version": 1, + "side_effect": "network", + "permission": "network.general", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + }, + { + "name": "web_fetch", + "schema_version": 1, + "side_effect": "network", + "permission": "network.general", + "deterministic": false, + "host": [ + "cli", + "desktop", + "cloud" + ] + } + ], + "permissions": [ + "workspace.read", + "workspace.write", + "workspace.outside", + "shell.test", + "shell.execute", + "git.read", + "git.stage", + "git.commit", + "git.push", + "network.github.read", + "network.general", + "network.loopback", + "secrets.read", + "billing.spend", + "artifact.publish" + ], + "permission_modes": [ + "ask", + "auto", + "skip" + ], + "effort_tiers": [ + "LOW", + "MED", + "HIGH", + "MAX", + "ULTRA", + "CODEPRO" + ], + "skill_schema_versions": [ + 1 + ], + "skill_context_contract_versions": [ + 1 + ], + "instruction_context_contract_versions": [ + 1 + ], + "instruction_source_types": [ + "aether-project", + "agents-root", + "agents-nested", + "aether-user", + "claude", + "gemini", + "copilot", + "cursor-rule" + ], + "doctor": { + "schema_versions": [ + 1, + 2 + ], + "modes": [ + "fast", + "network", + "live", + "fix" + ], + "categories": [ + "runtime", + "installation", + "configuration", + "workspace", + "git", + "auth", + "transport", + "tools", + "permissions", + "skills", + "instructions", + "memory", + "mcp", + "artifacts", + "persistence", + "support" + ] + }, + "support_bundle_schema_versions": [ + 1 + ], + "skill_error_codes": [ + "skill.untrusted", + "skill.changed", + "skill.disabled", + "skill.ambiguous", + "skill.not_found", + "skill.schema_invalid", + "skill.version_incompatible", + "skill.dependency_missing", + "skill.dependency_cycle", + "skill.context_budget_exceeded", + "skill.tool_not_declared", + "skill.permission_unavailable", + "skill.permission_denied", + "skill.resource_unsafe", + "skill.resource_changed", + "skill.server_unsupported" + ], + "limits": { + "max_skills_per_turn": 6, + "max_automatic_skills_per_turn": 3, + "max_loaded_skill_tokens": 16000, + "max_instruction_sources": 12, + "max_instruction_file_bytes": 65536, + "max_skill_context_bytes": 262144, + "max_instruction_context_bytes": 196608, + "max_skills_in_context": 6, + "max_instruction_sources_in_context": 12 + }, + "client_features": [ + "skills", + "instructions", + "doctor_v2", + "support_bundle", + "capability_matrix" + ] +} as const; + +/** sha256 over the canonical (sorted-keys, compact) JSON encoding. */ +export const AGENT_CAPABILITIES_DIGEST = "8da094234a370a28dfd6206f039425f086307aa9ca0a67bc004d3d453716ac04"; + +export const AGENT_CAPABILITIES_SOURCE = { + repository: "AetherAI3/AETHER-CLOUD", + commit: "97eacd3e9aca4df226cae638f8f8868b8219fe88", + contractVersion: 1, +} as const; diff --git a/test/redaction.test.ts b/test/redaction.test.ts new file mode 100644 index 0000000..c0f3e81 --- /dev/null +++ b/test/redaction.test.ts @@ -0,0 +1,80 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { homedir } from "node:os"; +import { + redactEnvValues, + redactForBundle, + redactHomeDir, + redactInline, + scanForSecrets, + SENSITIVE_KEY, +} from "../src/core/redaction.js"; + +test("redactInline keeps session_log's exact contract", () => { + // Both patterns fire here — the authorization key/value pass also swallows + // the word "Bearer"; identical to session_log's original private helper. + assert.equal(redactInline("Authorization: Bearer abc.def-123"), "Authorization: [REDACTED] [REDACTED]"); + assert.equal(redactInline("token=sk-live-abcdef status=ok"), "token=[REDACTED] status=ok"); + assert.equal(redactInline("api_key: 12345 next"), "api_key: [REDACTED] next"); + assert.equal(redactInline("x".repeat(600)).length, 512); + assert.equal(redactInline("plain text stays"), "plain text stays"); +}); + +test("jwt-shaped strings are redacted and detected", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.c2ln"; + const redacted = redactForBundle("id " + jwt + " end", {}); + assert.equal(redacted.includes(jwt), false); + assert.match(redacted, /\[REDACTED-JWT\]/); + assert.deepEqual(scanForSecrets("value " + jwt, {}), ["jwt-shaped string"]); + assert.deepEqual(scanForSecrets(redacted, {}), []); +}); + +test("bearer tokens are redacted and detected", () => { + const redacted = redactForBundle("Bearer abcDEF123.z", {}); + assert.equal(redacted, "Bearer [REDACTED]"); + assert.deepEqual(scanForSecrets("Bearer abcDEF123.z", {}), ["bearer token"]); + assert.deepEqual(scanForSecrets(redacted, {}), []); +}); + +test("hex secrets are scrubbed only in sensitive key positions", () => { + const hex = "deadbeef".repeat(8); + const json = `{"api_key": "${hex}", "sha256": "${hex}"}`; + const redacted = redactForBundle(json, {}); + assert.equal(redacted.includes(`"api_key": "[REDACTED]"`), true); + assert.equal(redacted.includes(`"sha256": "${hex}"`), true); + assert.deepEqual(scanForSecrets(json, {}), ["hex secret in sensitive key position"]); + assert.deepEqual(scanForSecrets(redacted, {}), []); + const pair = redactForBundle("client_secret=" + hex, {}); + assert.equal(pair.includes(hex), false); +}); + +test("urls with userinfo lose the userinfo", () => { + const redacted = redactForBundle("see https://alice:hunter2secret@example.test/path", {}); + assert.equal(redacted.includes("hunter2secret"), false); + assert.match(redacted, /https:\/\/\[REDACTED\]@example\.test\/path/); + assert.deepEqual(scanForSecrets("https://alice:pw12345678@example.test", {}), ["url with userinfo"]); + assert.deepEqual(scanForSecrets(redacted, {}), []); +}); + +test("sensitive environment values are scrubbed wherever they appear", () => { + const env = { MY_API_TOKEN: "supersecretvalue42", HARMLESS: "supersafe" }; + assert.equal(redactEnvValues("x supersecretvalue42 y", env), "x [REDACTED] y"); + assert.equal(redactEnvValues("x supersafe y", env), "x supersafe y"); + assert.deepEqual(scanForSecrets("contains supersecretvalue42", env), ["sensitive environment value"]); + assert.deepEqual(scanForSecrets("contains [REDACTED]", env), []); +}); + +test("home directory prefixes collapse to ~", () => { + const home = homedir(); + assert.equal(redactHomeDir(home + "/projects/app"), "~/projects/app"); + const escaped = JSON.stringify({ path: home + "\\x" }); + assert.equal(redactHomeDir(escaped).includes("~"), true); + assert.equal(redactHomeDir(escaped).includes(JSON.stringify(home).slice(1, -1)), false); +}); + +test("SENSITIVE_KEY matches the same key classes session_log relied on", () => { + for (const key of ["token", "API_KEY", "private-key", "Authorization", "credential", "password", "client_secret"]) { + assert.equal(SENSITIVE_KEY.test(key), true, key); + } + assert.equal(SENSITIVE_KEY.test("username"), false); +}); diff --git a/test/support_bundle.test.ts b/test/support_bundle.test.ts new file mode 100644 index 0000000..c03c215 --- /dev/null +++ b/test/support_bundle.test.ts @@ -0,0 +1,198 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import type { AppContext } from "../src/core/context.js"; +import type { MemoryRoots } from "../src/core/memory.js"; +import { LocalMcpStore } from "../src/core/mcp_store.js"; +import { createSupportBundle, SUPPORT_BUNDLE_FILES } from "../src/core/support_bundle.js"; +import { readTar } from "../src/core/tar.js"; + +const NOW = "2026-08-14T12:00:00.000Z"; +const TOKEN_CANARY = "CANARY-TOKEN-1234567890"; +const ENV_CANARY = "ENVCANARY-abcdef-0123456789"; +const JWT_CANARY = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjYW5hcnkifQ.c2lnbmF0dXJl"; +const BODY_CANARY = "SKILLBODYCANARY must never be exported"; + +interface Fixture { + ctx: AppContext; + cwd: string; + outDir: string; + dependencies: { memoryRoots: MemoryRoots; mcpStore: LocalMcpStore }; + restore(): void; +} + +function setup(): Fixture { + const root = mkdtempSync(join(tmpdir(), "aether-bundle-")); + const cwd = join(root, "workspace"); + const outDir = join(root, "out"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(join(cwd, ".git")); + mkdirSync(outDir); + const configDir = join(root, "config"); + mkdirSync(configDir); + const logsDir = join(root, "logs"); + const session = join(logsDir, "session-1"); + mkdirSync(session, { recursive: true }); + writeFileSync( + join(session, "events.jsonl"), + JSON.stringify({ ts: NOW, type: "error", msg: "token=" + TOKEN_CANARY + " jwt " + JWT_CANARY }) + "\n" + + JSON.stringify({ ts: NOW, type: "stage", name: "plan" }) + "\n", + "utf8", + ); + // Project skill whose body must never appear in an inventory. + const skillDir = join(cwd, ".aether", "skills", "project", "canary"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "skill.json"), + JSON.stringify({ + schema_version: 1, + id: "project/canary", + version: "0.1.0", + name: "canary", + description: "Fixture skill.", + entrypoint: "SKILL.md", + triggers: { commands: [], phrases: [], automatic: false }, + tools: { allowed: [], required: [], denied: [] }, + permissions: { requires: [], may_request: [], forbids: [] }, + context: { max_tokens: 2000, resources: [] }, + outputs: { kinds: [], verification: [] }, + dependencies: { skills: [] }, + compatibility: { min_agent_version: "0.1.0", capability_contract: 1 }, + health: { eval_manifest: null }, + }) + "\n", + "utf8", + ); + writeFileSync(join(skillDir, "SKILL.md"), "# canary\n\n" + BODY_CANARY + "\n", "utf8"); + + const previousConfig = process.env["AETHER_CONFIG_DIR"]; + const previousLogs = process.env["AETHER_LOG_DIR"]; + const previousSecret = process.env["AETHER_TEST_SECRET"]; + process.env["AETHER_CONFIG_DIR"] = configDir; + process.env["AETHER_LOG_DIR"] = logsDir; + process.env["AETHER_TEST_SECRET"] = ENV_CANARY; + + const logs = join(root, "mem-logs"); + const snapshots = join(root, "mem-snapshots"); + mkdirSync(logs); + mkdirSync(snapshots); + const goals = join(root, "goals.json"); + writeFileSync(goals, "[]"); + const memoryRoots: MemoryRoots = { + logs, + snapshots, + goals, + history: join(root, "history"), + legacyHistory: join(root, "legacy-history"), + }; + + const ctx = { + cfg: { + baseUrl: "https://user:" + TOKEN_CANARY + "@api.example.test/cloud", + defaultModel: "", + permissionMode: "ask", + autoApply: false, + telemetry: true, + defaultEffort: "", + backend: "auto", + }, + flags: { cwd, json: false, audit: false, yes: false }, + tokens: { get: async () => TOKEN_CANARY }, + api: { getJson: async () => ({}) }, + confirm: async () => false, + } as unknown as AppContext; + + return { + ctx, + cwd, + outDir, + dependencies: { memoryRoots, mcpStore: new LocalMcpStore(join(root, "mcp.json")) }, + restore: () => { + const put = (key: string, value: string | undefined): void => { + if (value == null) delete process.env[key]; + else process.env[key] = value; + }; + put("AETHER_CONFIG_DIR", previousConfig); + put("AETHER_LOG_DIR", previousLogs); + put("AETHER_TEST_SECRET", previousSecret); + }, + }; +} + +test("bundle contains only allowlisted, canary-free, hash-verified entries", async () => { + const fixture = setup(); + try { + const result = await createSupportBundle(fixture.ctx, { + now: NOW, + outDir: fixture.outDir, + dependencies: fixture.dependencies, + }); + assert.equal(basename(result.path), "aether-support-20260814-120000.tar"); + const archive = readFileSync(result.path); + assert.equal(result.bytes, archive.length); + assert.equal(result.sha256, createHash("sha256").update(archive).digest("hex")); + + const entries = readTar(archive); + assert.deepEqual( + entries.map((entry) => entry.name).sort(), + [...SUPPORT_BUNDLE_FILES].sort(), + ); + for (const entry of entries) { + const text = entry.data.toString("utf8"); + for (const canary of [TOKEN_CANARY, ENV_CANARY, JWT_CANARY, BODY_CANARY, "SKILLBODYCANARY"]) { + assert.equal(text.includes(canary), false, canary + " leaked into " + entry.name); + } + } + + const byName = new Map(entries.map((entry) => [entry.name, entry.data])); + const manifest = JSON.parse(byName.get("support-manifest.json")!.toString("utf8")) as { + files: { name: string; sha256: string; bytes: number }[]; + }; + assert.equal(manifest.files.length, SUPPORT_BUNDLE_FILES.length - 1); + for (const record of manifest.files) { + const data = byName.get(record.name)!; + assert.equal(createHash("sha256").update(data).digest("hex"), record.sha256); + assert.equal(data.length, record.bytes); + } + + // Inventories are metadata: ids/digests present, content absent. + const skills = JSON.parse(byName.get("skill-inventory.json")!.toString("utf8")) as { + skills: { id: string; digest: string }[]; + }; + assert.equal(skills.skills.some((skill) => skill.id === "project/canary"), true); + assert.match(skills.skills[0]!.digest, /^sha256:[0-9a-f]{64}$/); + + const config = JSON.parse(byName.get("sanitized-config.json")!.toString("utf8")) as Record; + assert.equal(config["base_url_host"], "api.example.test"); + assert.equal("baseUrl" in config, false); + + const events = byName.get("recent-redacted-events.ndjson")!.toString("utf8"); + assert.match(events, /\[REDACTED\]/); + assert.match(events, /\[REDACTED-JWT\]/); + } finally { + fixture.restore(); + } +}); + +test("interrupted generation leaves no final bundle file", async () => { + const fixture = setup(); + try { + await assert.rejects( + createSupportBundle(fixture.ctx, { + now: NOW, + outDir: fixture.outDir, + dependencies: fixture.dependencies, + verifyHook: () => { + throw new Error("simulated mid-write interruption"); + }, + }), + /simulated mid-write interruption/, + ); + assert.deepEqual(readdirSync(fixture.outDir).filter((name) => name.endsWith(".tar")), []); + assert.equal(existsSync(join(fixture.outDir, "aether-support-20260814-120000.tar")), false); + } finally { + fixture.restore(); + } +}); From fd4e59710d7c4cd585b78778feb3d124bec7648d Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 06:05:07 -0400 Subject: [PATCH 04/11] feat(doctor): skill and instruction health checks on the three-axis contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR A0.2 of the SC-A0 Skills & Health integration rescue, part 2 of 2. Ports the only two checks PR #71 has that main lacks — skills and instructions — from PR #71's CheckSpec onto main's DiagnosticCheckSpec/CheckOutcome, and wires them into fast mode. Six checks land: skills.index store integrity and index errors skills.lock lockfile presence and drift skills.trust project skills untrusted or changed skills.evals declared eval manifests instructions.graph source count and parse warnings instructions.conflicts detected conflicts, by topic This is a shape translation, not a rewrite. PR #71 modelled a result as a status plus a detail string with configured/reachable/verified as plain booleans; each axis here carries its own state and evidence. The translation is where the contract decision earns its keep. These checks touch only the filesystem, so `reachable` is n/a — there is no remote to reach — rather than a pass borrowed from a probe that never ran. Under PR #71's booleans there was no way to say that: `reachable: false` would have read as unreachable. `verified` is a genuine yes/no because these checks do exercise the files they report on during the run. The axis mapping lives in one helper (`localOutcome`) rather than being repeated per check, so a future check cannot quietly claim a `verified` it did not earn. Extends, rather than relaxes, the frozen check-ID inventory in test/diagnostics.test.ts. That assertion exists to catch unintended drift; this drift is intended, so the expected list grows and the deep-equal stays exact. Adds an assertion that both new categories report `reachable: "na"`, so a later change cannot silently upgrade them to a pass. Live proof against the built CLI at this commit — `aether doctor --json`: skills.index cfg=yes reach=na ver=yes 0 skill(s) indexed skills.lock cfg=yes reach=na ver=yes no project skills, no lock required skills.trust cfg=yes reach=na ver=yes no project skills awaiting trust review skills.evals cfg=yes reach=na ver=yes no skills discovered instructions.graph cfg=yes reach=na ver=yes 0 instruction source(s), no parse warnings instructions.conflicts cfg=yes reach=na ver=yes no instruction conflicts detected The emitted payload contains no boolean-shaped `verified` field, confirming PR #71's competing schema-v2 contract is absent rather than merely unused. "0 skill(s) indexed" is truthful, not a defect: the built-in skill assets are not packaged until A0.3. Gates at this commit: npm run typecheck exit 0 npm test 1017 pass / 0 fail --- src/core/diagnostics.ts | 4 + src/core/diagnostics_skills.ts | 205 +++++++++++++++++++++++++++++++++ test/diagnostics.test.ts | 17 +++ 3 files changed, 226 insertions(+) create mode 100644 src/core/diagnostics_skills.ts diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts index 063ddb3..e3df16a 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -18,6 +18,7 @@ import { localMemoryReport, type MemoryRoots } from "./memory.js"; import { LocalMcpStore } from "./mcp_store.js"; import type { McpClient } from "./mcp.js"; import { custodyLogPath } from "./custody.js"; +import { skillCheckSpecs } from "./diagnostics_skills.js"; import { ghAvailable } from "./repo.js"; import { planOpen } from "./opener.js"; import { historyPaths, loadHistory } from "./media_history.js"; @@ -455,6 +456,9 @@ export function fastCheckSpecs( severity: "info", }), }, + // Skill and instruction health. Filesystem-only, so they belong in fast + // mode alongside the rest: no network call, no write, no token spend. + ...skillCheckSpecs(ctx), ]; } diff --git a/src/core/diagnostics_skills.ts b/src/core/diagnostics_skills.ts new file mode 100644 index 0000000..21b0109 --- /dev/null +++ b/src/core/diagnostics_skills.ts @@ -0,0 +1,205 @@ +// Skill and instruction health checks for `aether doctor` fast mode. +// +// Ported from PR #71's src/core/diagnostics/{skills,instructions}.ts onto this +// tree's three-axis HealthCheck contract. PR #71 modelled a result as a status +// plus a detail string, with configured/reachable/verified as plain booleans. +// Here each axis carries its own state and evidence, which is what keeps +// "checked and failed" distinct from "never checked". +// +// Every check below is filesystem-only: no network, no writes, no token spend. +// `reachable` is therefore not-applicable rather than a pass — there is no +// remote endpoint to reach — and `verified` is a real yes/no, because these +// checks genuinely exercise the files they report on during this run. +// +// Details carry counts, ids and short labels only. Never file bodies. + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { AppContext } from "./context.js"; +import { axis, notApplicable, type Axis } from "./health.js"; +import type { CheckOutcome, DiagnosticCheckSpec } from "./diagnostics.js"; +import { discoverSkills } from "./skills/skill_discovery.js"; +import type { SkillDescriptor, SkillIndex } from "./skills/skill_types.js"; +import { compareLock, projectLockPath, readSkillLock } from "./skills/skill_lock.js"; +import { skillSettingsPath } from "./skills/skill_settings.js"; +import { trustStorePath } from "./skills/skill_trust.js"; +import { resolveInstructionGraph } from "./instructions/instruction_resolver.js"; +import type { InstructionGraph } from "./instructions/instruction_types.js"; + +/** Nothing here talks to a network, so the axis is absent rather than passing. */ +const LOCAL_ONLY = "local filesystem — no remote to reach"; + +/** + * Shared shape for these checks: a local invariant either holds or it does not, + * and either way this run actually looked. Keeping the mapping in one place is + * what stops a future check from quietly claiming a `verified` it did not earn. + */ +function localOutcome(ok: boolean, evidence: string, severity: CheckOutcome["severity"]): CheckOutcome { + const state: Axis["state"] = ok ? "yes" : "no"; + return { + configured: axis(state, { evidence }), + reachable: notApplicable(LOCAL_ONLY), + verified: axis(state), + severity: ok ? "info" : severity, + }; +} + +/** True when a local skill store exists but is not a schema-shaped object. */ +function storeCorrupt(path: string, listKey: string): boolean { + if (!existsSync(path)) return false; + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + return typeof raw !== "object" || raw === null || !Array.isArray(raw[listKey]); + } catch { + return true; + } +} + +/** + * Discovery walks the filesystem and four checks want the same answer, so + * memoize: one `aether doctor` run indexes once. + */ +function memoize(fn: () => T): () => T { + let cached: { value: T } | null = null; + return () => { + if (cached === null) cached = { value: fn() }; + return cached.value; + }; +} + +export function skillCheckSpecs(ctx: AppContext): DiagnosticCheckSpec[] { + const projectRoot = resolve(ctx.flags.cwd); + const skillIndex = memoize(() => discoverSkills({ projectRoot })); + const instructionGraph = memoize(() => resolveInstructionGraph(projectRoot)); + + return [ + { + id: "skills.index", + category: "skills", + title: "Skill index", + run: (): CheckOutcome => { + const corrupt = + Number(storeCorrupt(skillSettingsPath(), "settings")) + + Number(storeCorrupt(trustStorePath(), "records")); + if (corrupt > 0) { + return localOutcome(false, `${corrupt} skill store file(s) unreadable — run: aether doctor --fix`, "error"); + } + const index = skillIndex(); + return localOutcome( + index.errors.length === 0, + index.errors.length === 0 + ? `${index.skills.length} skill(s) indexed` + : `${index.skills.length} skill(s) indexed, ${index.errors.length} index error(s) — run: aether skills check --all`, + "warning", + ); + }, + }, + { + id: "skills.lock", + category: "skills", + title: "Skill lockfile", + run: (): CheckOutcome => { + const projectSkills = skillIndex().skills.filter((descriptor: SkillDescriptor) => descriptor.scope === "project"); + const lock = readSkillLock(projectLockPath(projectRoot)); + if (!lock.ok) { + // No project skills means no lock is owed — that is a pass, not a gap. + if (lock.missing && projectSkills.length === 0) { + return localOutcome(true, "no project skills, no lock required", "warning"); + } + return localOutcome( + false, + `${lock.missing ? "lock file missing" : "lock file unreadable"} — run: aether skills lock`, + "warning", + ); + } + const drift = compareLock( + lock.lock, + new Map(projectSkills.map((descriptor: SkillDescriptor) => [descriptor.id, descriptor.sha256])), + ); + const drifted = drift.unlocked.length + drift.changed.length + drift.missing.length; + return localOutcome( + drifted === 0, + drifted === 0 + ? "lock matches discovered project skills" + : `lock drift: ${drift.unlocked.length} unlocked, ${drift.changed.length} changed, ${drift.missing.length} missing — run: aether skills lock`, + "warning", + ); + }, + }, + { + id: "skills.trust", + category: "skills", + title: "Project skill trust", + run: (): CheckOutcome => { + // A content change invalidates prior trust, so "changed" counts as + // untrusted here rather than as a still-approved skill. + const untrusted = skillIndex().skills.filter( + (descriptor: SkillDescriptor) => + descriptor.scope === "project" && + (descriptor.trust === "untrusted" || descriptor.trust === "changed"), + ); + return localOutcome( + untrusted.length === 0, + untrusted.length === 0 + ? "no project skills awaiting trust review" + : `${untrusted.length} project skill(s) untrusted or changed — run: aether skills trust `, + "warning", + ); + }, + }, + { + id: "skills.evals", + category: "skills", + title: "Skill eval manifests", + run: (): CheckOutcome => { + const index = skillIndex(); + if (index.skills.length === 0) return localOutcome(true, "no skills discovered", "info"); + const missing = index.skills.filter((descriptor: SkillDescriptor) => !descriptor.manifest.health.evalManifest); + return localOutcome( + missing.length === 0, + missing.length === 0 + ? "all skills declare eval manifests" + : `${missing.length} of ${index.skills.length} skill(s) declare no eval manifest — add health.eval_manifest`, + "info", + ); + }, + }, + { + id: "instructions.graph", + category: "instructions", + title: "Instruction sources", + run: (): CheckOutcome => { + const graph = instructionGraph(); + // A source that failed to parse counts even when it emitted no warning + // of its own — silently dropping an AGENTS.md is the failure. + const parseWarnings = + graph.sources.reduce( + (sum, source) => sum + source.warnings.length + (source.parseStatus === "ok" ? 0 : 1), + 0, + ) + graph.skipped.length; + return localOutcome( + parseWarnings === 0, + parseWarnings === 0 + ? `${graph.sources.length} instruction source(s), no parse warnings` + : `${graph.sources.length} instruction source(s), ${parseWarnings} parse warning(s) — review the flagged files`, + "warning", + ); + }, + }, + { + id: "instructions.conflicts", + category: "instructions", + title: "Instruction conflicts", + run: (): CheckOutcome => { + const conflicts = instructionGraph().conflicts; + return localOutcome( + conflicts.length === 0, + conflicts.length === 0 + ? "no instruction conflicts detected" + : `${conflicts.length} conflict(s): ${conflicts.map((conflict) => conflict.topic).join(", ")} — align the higher-precedence source`, + "warning", + ); + }, + }, + ]; +} diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index 92eae6b..f81698a 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -155,10 +155,27 @@ test("fast doctor is local, ordered, fail-soft, and content-redacted", async () "custody.receipts", "actions.dispatch", "predator.readiness", + + "skills.index", + + "skills.lock", + + "skills.trust", + + "skills.evals", + + "instructions.graph", + + "instructions.conflicts", ]); // The whole point of fast mode: nothing remote was contacted, and the report // says so instead of implying otherwise. assert.deepEqual(counters, { backend: 0, broker: 0 }); + // Skill and instruction checks are filesystem-only: they must declare + // reachable as n/a rather than borrowing a pass they never earned. + for (const id of ["skills.index", "instructions.graph"]) { + assert.equal(report.checks.find((check) => check.id === id)?.reachable.state, "na", id); + } assert.equal( report.checks.find((check) => check.id === "agent.transport")?.reachable.state, "not-checked", From bb31a33748dd59a5de94bdb0c0619da1957e2ce7 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 06:14:43 -0400 Subject: [PATCH 05/11] feat(cli): aether skills, capabilities and support-bundle, with packaged assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR A0.3 of the SC-A0 Skills & Health integration rescue. Makes the work in A0.1 and A0.2 reachable. Until this commit the skill runtime existed but no user could invoke it. aether skills list, show, check, trust, lock aether capabilities capability contract, with --available aether support-bundle redacted metadata-only diagnostic archive Built-in skill assets (SKILL.md, skill.json, evals) ship as data, which tsc does not emit, so `build` now runs scripts/copy-skill-assets.js after compile. The existing `files: ["dist/src"]` entry already carries them into the tarball; verified below rather than assumed. Deliberately NOT taken from PR #71: * Its cli_registry entry rewriting `doctor [--deep]` to `[--network] [--fix]`. `--deep` stays read-only per decision C2 in the conflict matrix; aliasing it to a network-performing mode would silently change what an existing habit does. `--network` is a separate, explicitly named mode when it lands. * Its main.ts doctor flags (--network, --schema, --category, --failed), which belong to the doctor engine this rescue drops. main's doctor parses its own argv, so it needs none of them. * Its `aether agent --skill` / `--no-skills` flags and dev-session skill context. Those touch code.ts, chat.ts, brain_cloud.ts and stream.ts, which SC-A1, SC-A4 and SC-A5 own. Deferred rather than raced. COMMANDS.md gains real sections, not just index entries. The parity test only asserts the index, but an index pointing at nothing is a doc that lies by omission. Every subcommand documented was then invoked to confirm it exists: skills list -> 6 built-ins listed skills show -> aether/ship@1.0.0 — Ship skills check -> ok skills trust -> "aether/ship is a builtin skill — already trusted" skills lock -> locked 0 project skills Live proof at this commit, against the built CLI: aether skills list 6 built-in skills, scope and trust shown aether capabilities contract v1, falls back to the packaged snapshot and states that it did so, and why aether support-bundle 25600-byte tar written, sha256 reported aether doctor --json skills.index now reports "6 skill(s) indexed" (it read 0 before the assets were packaged) Gates at this commit: npm run typecheck exit 0 npm test 1017 pass / 0 fail npm run verify:production exit 0 — ok:true, 518 packed files, 2315934 bytes npm pack --dry-run 18 dist/src/skills/builtin/** entries present The COMMANDS.md parity test failed first on this change, correctly: the CLI registry had grown and the doc had not. Fixed by documenting the commands, not by relaxing the assertion. --- .aether/skills.lock.json | 4 + COMMANDS.md | 46 +- package.json | 2 +- scripts/copy-skill-assets.ts | 58 ++ src/commands/capabilities.ts | 34 + src/commands/cli_registry.ts | 3 + src/commands/skills.ts | 608 ++++++++++++++++++ src/commands/support_bundle.ts | 43 ++ src/main.ts | 24 + src/skills/builtin/doctor-project/SKILL.md | 34 + .../builtin/doctor-project/evals/cases.json | 19 + src/skills/builtin/doctor-project/skill.json | 68 ++ src/skills/builtin/fix-ci/SKILL.md | 31 + src/skills/builtin/fix-ci/evals/cases.json | 29 + src/skills/builtin/fix-ci/skill.json | 73 +++ .../builtin/frontend-from-screenshot/SKILL.md | 35 + .../frontend-from-screenshot/evals/cases.json | 19 + .../frontend-from-screenshot/skill.json | 67 ++ .../builtin/research-and-implement/SKILL.md | 39 ++ .../research-and-implement/evals/cases.json | 19 + .../builtin/research-and-implement/skill.json | 71 ++ src/skills/builtin/review-pr/SKILL.md | 31 + src/skills/builtin/review-pr/evals/cases.json | 29 + src/skills/builtin/review-pr/skill.json | 71 ++ src/skills/builtin/ship/SKILL.md | 30 + src/skills/builtin/ship/evals/cases.json | 19 + src/skills/builtin/ship/skill.json | 69 ++ 27 files changed, 1572 insertions(+), 3 deletions(-) create mode 100644 .aether/skills.lock.json create mode 100644 scripts/copy-skill-assets.ts create mode 100644 src/commands/capabilities.ts create mode 100644 src/commands/skills.ts create mode 100644 src/commands/support_bundle.ts create mode 100644 src/skills/builtin/doctor-project/SKILL.md create mode 100644 src/skills/builtin/doctor-project/evals/cases.json create mode 100644 src/skills/builtin/doctor-project/skill.json create mode 100644 src/skills/builtin/fix-ci/SKILL.md create mode 100644 src/skills/builtin/fix-ci/evals/cases.json create mode 100644 src/skills/builtin/fix-ci/skill.json create mode 100644 src/skills/builtin/frontend-from-screenshot/SKILL.md create mode 100644 src/skills/builtin/frontend-from-screenshot/evals/cases.json create mode 100644 src/skills/builtin/frontend-from-screenshot/skill.json create mode 100644 src/skills/builtin/research-and-implement/SKILL.md create mode 100644 src/skills/builtin/research-and-implement/evals/cases.json create mode 100644 src/skills/builtin/research-and-implement/skill.json create mode 100644 src/skills/builtin/review-pr/SKILL.md create mode 100644 src/skills/builtin/review-pr/evals/cases.json create mode 100644 src/skills/builtin/review-pr/skill.json create mode 100644 src/skills/builtin/ship/SKILL.md create mode 100644 src/skills/builtin/ship/evals/cases.json create mode 100644 src/skills/builtin/ship/skill.json diff --git a/.aether/skills.lock.json b/.aether/skills.lock.json new file mode 100644 index 0000000..3f03290 --- /dev/null +++ b/.aether/skills.lock.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "skills": [] +} diff --git a/COMMANDS.md b/COMMANDS.md index 46ab6e7..f1fdc5a 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -15,8 +15,9 @@ aether # no args = interactive REPL `help`, `agent`, `chat`, `resume`, `run`, `models`, `agents`, `auth`, -`github`, `vault`, `workflow`, `memory`, `image`, `video`, `output`, `audit`, -`receipt`, `doctor`, `mcp`, `config` +`github`, `vault`, `workflow`, `memory`, `skills`, `capabilities`, `image`, +`video`, `output`, `audit`, `receipt`, `doctor`, `support-bundle`, `mcp`, +`config` @@ -201,6 +202,47 @@ aether doctor --json # schema-versioned report for automation `--deep` still means the read-only report it always meant; it now points at `--live` for the end-to-end proof. +### `aether skills ` — inspect, trust, and manage agent skills + +Skills are packaged instructions the agent can load. Built-in skills ship with +the package; user skills live under your config directory; project skills live +in the repository you are working in. + +```bash +aether skills list # every discovered skill, with scope and trust +aether skills show # one skill: manifest, declared tools, digest +aether skills check [--all] # validate manifests and report index errors +aether skills trust # approve a project skill at its current digest +aether skills lock # pin discovered project skills to a lockfile +``` + +**Project skills are untrusted until you approve them.** Trust is bound to the +skill's content digest, so editing a trusted skill revokes that trust until you +approve the new digest. A skill declaration narrows what the agent may do — it +never grants a tool the host would otherwise refuse. + +### `aether capabilities [--available]` — what this build can actually do + +Prints the capability contract: tools, their side-effect class, and the +permission each requires. `--available` additionally reports what is usable in +your current session rather than what exists in principle. + +The command prefers the server manifest and falls back to a packaged snapshot +when the server cannot be reached. It says which one it used, and why, rather +than presenting stale data as live. + +### `aether support-bundle` — a redacted diagnostic archive + +Writes a `.tar` of metadata-only diagnostics for troubleshooting: a fast doctor +report, runtime facts, sanitized config, and skill and instruction inventories. + +It carries counts, ids and digests — never prompts, file contents, tokens, +environment values or raw command text. Every entry is scanned before the +archive is finalized; if a secret is detected the bundle is refused rather than +written, and an interrupted run leaves no partial file behind. + +Like `aether doctor`, it makes no network call and spends nothing. + ### `aether mcp [list|doctor|repair]` — manage and diagnose MCP servers With no subcommand (in a TTY), opens the same interactive MCP manager as the `/mcp` slash command: an arrow-key menu over backend connections (OAuth/PAT diff --git a/package.json b/package.json index 9093bf3..11cfcee 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "node": ">=24" }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && node dist/scripts/copy-skill-assets.js", "typecheck": "tsc -p tsconfig.json --noEmit", "dev": "npm run build && node dist/src/main.js", "start": "node dist/src/main.js", diff --git a/scripts/copy-skill-assets.ts b/scripts/copy-skill-assets.ts new file mode 100644 index 0000000..948a650 --- /dev/null +++ b/scripts/copy-skill-assets.ts @@ -0,0 +1,58 @@ +// Post-build asset copy: tsc emits only compiled .ts output, but built-in +// skills ship as data (skill.json, SKILL.md, references/**). Copy them next to +// the compiled tree so builtinSkillsRoot() (dist/src/core/skills → +// ../../skills/builtin, i.e. dist/src/skills/builtin) resolves in a build and +// in the packed artifact. Deterministic (sorted walk), zero dependencies. + +import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Compiled location: /dist/scripts/copy-skill-assets.js +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, "..", ".."); +const sourceRoot = join(repoRoot, "src", "skills", "builtin"); +const destRoot = join(repoRoot, "dist", "src", "skills", "builtin"); + +function copyTree(source: string, destination: string): number { + let copied = 0; + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source).sort()) { + const from = join(source, entry); + const to = join(destination, entry); + if (statSync(from).isDirectory()) copied += copyTree(from, to); + else { + copyFileSync(from, to); + copied += 1; + } + } + return copied; +} + +function main(): void { + if (!existsSync(sourceRoot)) { + process.stdout.write("no built-in skills at " + sourceRoot + " — nothing to copy\n"); + return; + } + let copied = 0; + for (const skillName of readdirSync(sourceRoot).sort()) { + const skillDir = join(sourceRoot, skillName); + if (!statSync(skillDir).isDirectory()) continue; + for (const asset of ["skill.json", "SKILL.md"]) { + const from = join(skillDir, asset); + if (!existsSync(from)) continue; + mkdirSync(join(destRoot, skillName), { recursive: true }); + copyFileSync(from, join(destRoot, skillName, asset)); + copied += 1; + } + for (const treeName of ["references", "evals", "templates"]) { + const tree = join(skillDir, treeName); + if (existsSync(tree) && statSync(tree).isDirectory()) { + copied += copyTree(tree, join(destRoot, skillName, treeName)); + } + } + } + process.stdout.write("copied " + copied + " built-in skill asset" + (copied === 1 ? "" : "s") + " → dist/src/skills/builtin\n"); +} + +main(); diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts new file mode 100644 index 0000000..a15dae6 --- /dev/null +++ b/src/commands/capabilities.ts @@ -0,0 +1,34 @@ +// aether capabilities — show the capability contract: static support (from +// the server manifest or the packaged fallback) separately from runtime +// availability (server overlay). Offline output says so; it never guesses. + +import type { AppContext } from "../core/context.js"; +import { fallbackCapabilities, renderCapabilities, resolveCapabilities } from "../core/capabilities.js"; + +export interface CapabilitiesCommandOptions { + available?: boolean; +} + +export async function cmdCapabilities( + ctx: AppContext, + argv: string[] = [], + options: CapabilitiesCommandOptions = {}, +): Promise { + const availableOnly = options.available === true || argv.includes("--available"); + const signedIn = Boolean(await ctx.tokens.get()); + const resolved = signedIn ? await resolveCapabilities(ctx.api) : fallbackCapabilities([ + "not signed in — showing the packaged snapshot; server availability unknown", + ]); + if (ctx.flags.json) { + process.stdout.write(JSON.stringify({ + source: resolved.source, + digest: resolved.digest, + warnings: resolved.warnings, + contract: resolved.contract, + overlay: resolved.overlay, + }) + "\n"); + } else { + process.stdout.write(renderCapabilities(resolved, availableOnly)); + } + return 0; +} diff --git a/src/commands/cli_registry.ts b/src/commands/cli_registry.ts index dcda2c3..ea0a00c 100644 --- a/src/commands/cli_registry.ts +++ b/src/commands/cli_registry.ts @@ -16,12 +16,15 @@ export const CLI_COMMANDS: CommandSpec[] = [ { name: "vault", args: "", summary: "search and manage semantic memory", section: "Knowledge" }, { name: "workflow", args: "", summary: "create and manage workflows", section: "Knowledge" }, { name: "memory", args: "[status|inspect|forget|prune]", summary: "inspect and manage scoped memory", section: "Knowledge" }, + { name: "skills", args: "", summary: "inspect, trust, and manage agent skills", section: "Knowledge" }, + { name: "capabilities", args: "[--available]", summary: "show the capability contract and runtime availability", section: "Knowledge" }, { name: "image", aliases: ["img"], args: "", summary: "generate an image", section: "Media" }, { name: "video", aliases: ["vid"], args: "", summary: "generate a video", section: "Media" }, { name: "output", aliases: ["out"], args: "[open ]", summary: "manage generated media", section: "Media" }, { name: "audit", args: "[limit]", summary: "show chain-of-custody events", section: "System" }, { name: "receipt", args: "", summary: "export an audit proof package", section: "System" }, { name: "doctor", args: "[--deep]", summary: "run structured runtime diagnostics", section: "System" }, + { name: "support-bundle", summary: "export a redacted diagnostic support bundle", section: "System" }, { name: "mcp", args: "[list|doctor|repair]", summary: "manage and diagnose MCP servers", section: "System" }, { name: "config", args: "[show|get|set]", summary: "inspect or change configuration", section: "System" }, ]; diff --git a/src/commands/skills.ts b/src/commands/skills.ts new file mode 100644 index 0000000..ef48865 --- /dev/null +++ b/src/commands/skills.ts @@ -0,0 +1,608 @@ +// aether skills — inspect, trust, and manage Agent Skills. +// +// Everything here is metadata-level: bodies (SKILL.md) are never printed and +// never loaded. Trust mutations are CLI-only by design — the REPL handler +// (skillsSlash) supports read/toggle subcommands and redirects trust to the +// CLI so a trust decision is always a deliberate, visible action. + +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { discoverSkills, projectSkillsRoot, userSkillsRoot } from "../core/skills/skill_discovery.js"; +import { calculateSkillDigest, digestFileList } from "../core/skills/skill_digest.js"; +import { + compareLock, projectLockPath, readSkillLock, writeSkillLock, + type LockDrift, type SkillLockEntry, +} from "../core/skills/skill_lock.js"; +import { TOOL_PERMISSIONS } from "../core/skills/permission_vocabulary.js"; +import { dependencyOrder } from "../core/skills/skill_resolver.js"; +import { validateSkillManifest, type SkillManifest, type SkillScope } from "../core/skills/skill_schema.js"; +import { loadSkillSettings, lookupSkillSetting, saveSkillSetting } from "../core/skills/skill_settings.js"; +import { recordTrust, removeTrust } from "../core/skills/skill_trust.js"; +import { runSkillEvals, renderEvalJUnit } from "../core/skills/skill_eval.js"; +import type { SkillDescriptor, SkillIndex } from "../core/skills/skill_types.js"; +import type { ToolName } from "../core/brain_protocol.js"; + +export interface SkillsCommandOptions { + out?: Writable; + /** --scope for create/install (project|user; default project). */ + scope?: string; + /** --all for check/eval. */ + all?: boolean; + /** --ci for check: exit 1 on any failure. */ + ci?: boolean; + /** --json for eval: structured report on stdout. */ + json?: boolean; + /** --junit for eval: write a JUnit XML report. */ + junit?: string; +} + +const USAGE = + "usage: aether skills \n" + + " list index of discovered skills (--json)\n" + + " show metadata, digest, declared policy\n" + + " explain show + files that would load + effective tools\n" + + " create [--scope project|user] scaffold a new skill directory\n" + + " install [--scope project|user] install a local skill directory\n" + + " enable | disable toggle a skill locally\n" + + " trust | untrust record / remove a local trust decision\n" + + " lock write .aether/skills.lock.json\n" + + " check [id|--all] [--ci] static health checks (--json)\n" + + " eval [id|--all] [--json] [--junit ] offline eval suites (zero model spend)\n"; + +export async function cmdSkills( + ctx: AppContext, + argv: string[] = [], + options: SkillsCommandOptions = {}, +): Promise { + const out = options.out ?? process.stdout; + // Flags are parsed by main.ts; drop any stray --tokens (REPL passthrough). + const positional = argv.filter((token) => !token.startsWith("--")); + const sub = (positional[0] ?? "list").toLowerCase(); + const target = positional[1]; + + switch (sub) { + case "list": + return runList(ctx, out); + case "show": + case "explain": + if (!target) return usage(out); + return runShow(ctx, out, target, sub === "explain"); + case "create": + if (!target) return usage(out); + return runCreate(ctx, out, target, options.scope); + case "install": + if (!target) return usage(out); + return runInstall(ctx, out, target, options.scope); + case "enable": + case "disable": + if (!target) return usage(out); + return runSetEnabled(ctx, out, target, sub === "enable"); + case "trust": + if (!target) return usage(out); + return runTrust(ctx, out, target); + case "untrust": + if (!target) return usage(out); + return runUntrust(ctx, out, target); + case "lock": + return runLock(ctx, out); + case "check": + return runCheck(ctx, out, options.all ? undefined : target, options.ci === true); + case "eval": + return runEval(ctx, out, options.all ? undefined : target, options); + default: + return usage(out); + } +} + +/** `/skills` REPL handler: read/toggle only — trust mutations stay in the CLI. */ +export async function skillsSlash(ctx: AppContext, out: Writable, arg: string): Promise { + const parts = arg.trim() ? arg.trim().split(/\s+/) : []; + const sub = (parts[0] ?? "list").toLowerCase(); + if (sub === "trust" || sub === "untrust") { + out.write(`trust is a deliberate CLI action — use: aether skills ${sub} ${parts[1] ?? ""}\n`); + return; + } + if (sub === "list" || sub === "show" || sub === "enable" || sub === "disable") { + await cmdSkills(ctx, parts, { out }); + return; + } + out.write("usage: /skills [list|show |enable |disable ]\n"); +} + +function usage(out: Writable): number { + out.write(USAGE); + return 2; +} + +function discover(ctx: AppContext): SkillIndex { + return discoverSkills({ projectRoot: ctx.flags.cwd }); +} + +/** Trust/settings key convention (skill_trust.ts): "*" except project scope. */ +function storeKey(ctx: AppContext, scope: SkillScope): string { + return scope === "project" ? resolve(ctx.flags.cwd) : "*"; +} + +function findSkill(index: SkillIndex, reference: string): { descriptor?: SkillDescriptor; error?: string } { + const query = reference.trim().toLowerCase(); + if (!query) return { error: "missing skill id" }; + const matches = query.includes("/") + ? index.skills.filter((descriptor) => descriptor.id === query) + : index.skills.filter((descriptor) => (descriptor.id.split("/")[1] ?? descriptor.id) === query); + if (matches.length === 0) return { error: `no skill matches '${reference}' — see: aether skills list` }; + if (matches.length > 1) { + return { error: `'${reference}' is ambiguous: ${matches.map((descriptor) => descriptor.id).join(", ")} — use the fully qualified id` }; + } + return { descriptor: matches[0] }; +} + +function listOrNone(values: readonly string[]): string { + return values.length ? values.join(", ") : "(none)"; +} + +function normalizeScope(value: string | undefined): "project" | "user" | null { + if (value == null || value === "") return "project"; + return value === "project" || value === "user" ? value : null; +} + +// ── list ──────────────────────────────────────────────────────────────────── + +function runList(ctx: AppContext, out: Writable): number { + const index = discover(ctx); + if (ctx.flags.json) { + out.write(JSON.stringify(index) + "\n"); + return 0; + } + if (index.skills.length === 0) out.write("no skills discovered.\n"); + const width = Math.max(0, ...index.skills.map((d) => (d.id + "@" + d.version).length)) + 2; + for (const d of index.skills) { + out.write( + (d.id + "@" + d.version).padEnd(width) + + d.scope.padEnd(9) + + d.trust.padEnd(11) + + (d.enabled ? "enabled" : "disabled").padEnd(10) + + (d.automatic ? "auto" : "manual").padEnd(8) + + "~" + d.approxTokens + " tok\n", + ); + } + if (index.errors.length) { + out.write("\nindex errors:\n"); + for (const error of index.errors) { + out.write(" " + error.root + " (" + error.scope + "): " + error.errors.join("; ") + "\n"); + } + } + return 0; +} + +// ── show / explain ────────────────────────────────────────────────────────── + +function runShow(ctx: AppContext, out: Writable, reference: string, explain: boolean): number { + const found = findSkill(discover(ctx), reference); + if (!found.descriptor) { + out.write(found.error + "\n"); + return 1; + } + const d = found.descriptor; + const m = d.manifest; + out.write(d.id + "@" + d.version + " — " + m.name + "\n"); + out.write(" " + m.description + "\n"); + out.write( + " scope: " + d.scope + " trust: " + d.trust + + " enabled: " + (d.enabled ? "yes" : "no") + + " automatic: " + (d.automatic ? "yes" : "no") + "\n", + ); + out.write(" root: " + d.root + "\n"); + out.write(" digest: sha256:" + d.sha256 + "\n"); + out.write(" entrypoint: " + m.entrypoint + " approx tokens: " + d.approxTokens + "\n"); + out.write(" tools allowed: " + listOrNone(m.tools.allowed) + "\n"); + out.write(" tools required: " + listOrNone(m.tools.required) + "\n"); + out.write(" tools denied: " + listOrNone(m.tools.denied) + "\n"); + out.write(" permissions requires: " + listOrNone(m.permissions.requires) + "\n"); + out.write(" permissions may_request: " + listOrNone(m.permissions.mayRequest) + "\n"); + out.write(" permissions forbids: " + listOrNone(m.permissions.forbids) + "\n"); + out.write(" resources: " + listOrNone(m.context.resources) + "\n"); + out.write( + " triggers: automatic=" + (m.triggers.automatic ? "yes" : "no") + + " commands=" + listOrNone(m.triggers.commands) + + " phrases=" + listOrNone(m.triggers.phrases) + "\n", + ); + out.write(" dependencies: " + listOrNone(m.dependencies.skills) + "\n"); + if (!explain) return 0; + + out.write("\nwould load (digest-covered files):\n"); + for (const file of digestFileList(m)) out.write(" " + file + "\n"); + out.write("effective tool policy (allowed minus forbidden-permission tools):\n"); + const effective = effectiveTools(m); + if (effective.length === 0) out.write(" (none)\n"); + for (const entry of effective) out.write(" " + entry.tool + " (needs " + entry.permission + ")\n"); + return 0; +} + +/** Mirror of calculateSkillPolicy without loading the body: a tool whose + * permission the skill itself forbids is never effective. */ +function effectiveTools(manifest: SkillManifest): { tool: string; permission: string }[] { + const out: { tool: string; permission: string }[] = []; + for (const tool of manifest.tools.allowed) { + const needed = TOOL_PERMISSIONS[tool as ToolName]; + if (needed && manifest.permissions.forbids.includes(needed)) continue; + out.push({ tool, permission: needed }); + } + return out; +} + +// ── create ────────────────────────────────────────────────────────────────── + +function runCreate(ctx: AppContext, out: Writable, name: string, scopeFlag: string | undefined): number { + const scope = normalizeScope(scopeFlag); + if (!scope) { + out.write("--scope must be project or user\n"); + return 2; + } + if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) { + out.write("skill name must be lowercase kebab (a-z, 0-9, -), at most 64 chars\n"); + return 2; + } + const root = scope === "project" ? projectSkillsRoot(resolve(ctx.flags.cwd)) : userSkillsRoot(); + const dir = join(root, name); + if (existsSync(dir)) { + out.write("directory already exists: " + dir + "\n"); + return 1; + } + const template = { + schema_version: 1, + id: scope + "/" + name, + version: "0.1.0", + name, + description: "Describe when this skill applies and what it does.", + entrypoint: "SKILL.md", + triggers: { commands: [], phrases: [], automatic: false }, + tools: { allowed: [], required: [], denied: [] }, + permissions: { requires: [], may_request: [], forbids: [] }, + context: { max_tokens: 2000, resources: [] }, + outputs: { kinds: [], verification: [] }, + dependencies: { skills: [] }, + compatibility: { min_agent_version: "0.1.0", capability_contract: 1 }, + health: { eval_manifest: null }, + }; + // Invariant: the scaffold must always be schema-valid — a template that fails + // its own validator would ship a broken starting point. + const validation = validateSkillManifest(template, scope); + if (!validation.ok) { + out.write("internal error — scaffold template is invalid:\n"); + for (const error of validation.errors) out.write(" - " + error + "\n"); + return 1; + } + mkdirSync(join(dir, "references"), { recursive: true }); + writeFileSync(join(dir, "skill.json"), JSON.stringify(template, null, 2) + "\n", "utf8"); + writeFileSync( + join(dir, "SKILL.md"), + "# " + name + "\n\nConcrete instructions for the agent when this skill is active.\n\n## Steps\n\n1. ...\n", + "utf8", + ); + out.write("created " + scope + "/" + name + " at " + dir + "\n"); + out.write("edit skill.json + SKILL.md, then verify: aether skills check " + scope + "/" + name + "\n"); + return 0; +} + +// ── install ───────────────────────────────────────────────────────────────── + +function runInstall(ctx: AppContext, out: Writable, sourceArg: string, scopeFlag: string | undefined): number { + const scope = normalizeScope(scopeFlag); + if (!scope) { + out.write("--scope must be project or user\n"); + return 2; + } + const source = resolve(ctx.flags.cwd, sourceArg); + const manifestPath = join(source, "skill.json"); + if (!existsSync(manifestPath)) { + out.write("no skill.json found at " + source + "\n"); + return 1; + } + let raw: unknown; + try { + raw = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + out.write("skill.json is not valid JSON: " + manifestPath + "\n"); + return 1; + } + const validation = validateSkillManifest(raw, scope); + if (!validation.ok) { + out.write("invalid skill manifest for scope '" + scope + "':\n"); + for (const error of validation.errors) out.write(" - " + error + "\n"); + return 1; + } + const manifest = validation.manifest; + const digest = calculateSkillDigest(source, manifest, raw); + if (!digest.ok) { + out.write(digest.error + "\n"); + return 1; + } + const shortName = manifest.id.split("/")[1] ?? manifest.id; + const root = scope === "project" ? projectSkillsRoot(resolve(ctx.flags.cwd)) : userSkillsRoot(); + const destination = join(root, shortName); + if (existsSync(destination)) { + out.write("destination already exists: " + destination + "\n"); + return 1; + } + mkdirSync(root, { recursive: true }); + cpSync(source, destination, { recursive: true }); + if (scope === "user") { + recordTrust({ + projectRoot: "*", + repository: null, + skillId: manifest.id, + version: manifest.version, + sha256: digest.sha256, + trustedAt: new Date().toISOString(), + method: "install", + requestedPermissions: [...manifest.permissions.requires, ...manifest.permissions.mayRequest], + }); + } + out.write("installed " + manifest.id + "@" + manifest.version + " → " + destination + "\n"); + if (scope === "project") { + out.write("project skills need trust before they run: aether skills trust " + manifest.id + "\n"); + } + return 0; +} + +// ── enable / disable ──────────────────────────────────────────────────────── + +function runSetEnabled(ctx: AppContext, out: Writable, reference: string, enabled: boolean): number { + const found = findSkill(discover(ctx), reference); + if (!found.descriptor) { + out.write(found.error + "\n"); + return 1; + } + const d = found.descriptor; + const key = storeKey(ctx, d.scope); + const existing = lookupSkillSetting(loadSkillSettings(), key, d.id); + saveSkillSetting({ projectRoot: key, skillId: d.id, enabled, automatic: existing?.automatic === true }); + out.write(d.id + " " + (enabled ? "enabled" : "disabled") + "\n"); + return 0; +} + +// ── trust / untrust ───────────────────────────────────────────────────────── + +async function runTrust(ctx: AppContext, out: Writable, reference: string): Promise { + const found = findSkill(discover(ctx), reference); + if (!found.descriptor) { + out.write(found.error + "\n"); + return 1; + } + const d = found.descriptor; + if (d.scope !== "project") { + out.write(d.id + " is a " + d.scope + " skill — already trusted, nothing to record\n"); + return 0; + } + const m = d.manifest; + out.write("trust decision for " + d.id + "@" + d.version + "\n"); + out.write(" digest: sha256:" + d.sha256 + "\n"); + out.write(" requires: " + listOrNone(m.permissions.requires) + "\n"); + out.write(" may_request: " + listOrNone(m.permissions.mayRequest) + "\n"); + out.write(" forbids: " + listOrNone(m.permissions.forbids) + "\n"); + out.write(" tools: " + listOrNone(m.tools.allowed) + "\n"); + if (!ctx.flags.yes && !process.stdin.isTTY) { + // Fail closed: piped/CI stdin cannot confirm, and trust must never default on. + out.write("not a TTY — re-run with --yes to trust non-interactively\n"); + return 1; + } + const confirmed = ctx.flags.yes || (await ctx.confirm("Trust this skill for this project? [y/N] ")); + if (!confirmed) { + out.write("not trusted.\n"); + return 0; + } + recordTrust({ + projectRoot: resolve(ctx.flags.cwd), + repository: null, + skillId: d.id, + version: d.version, + sha256: d.sha256, + trustedAt: new Date().toISOString(), + method: "explicit", + requestedPermissions: [...m.permissions.requires, ...m.permissions.mayRequest], + }); + out.write(d.id + " trusted (digest-bound — content changes require re-trust)\n"); + return 0; +} + +function runUntrust(ctx: AppContext, out: Writable, reference: string): number { + const found = findSkill(discover(ctx), reference); + const skillId = found.descriptor?.id ?? reference; + const key = found.descriptor ? storeKey(ctx, found.descriptor.scope) : resolve(ctx.flags.cwd); + const removed = removeTrust(key, skillId); + out.write(removed ? skillId + " trust record removed\n" : "no trust record for " + skillId + "\n"); + return removed ? 0 : 1; +} + +// ── lock ──────────────────────────────────────────────────────────────────── + +function runLock(ctx: AppContext, out: Writable): number { + const index = discover(ctx); + const projectRoot = resolve(ctx.flags.cwd); + const entries: SkillLockEntry[] = index.skills + .filter((d) => d.scope === "project") + .map((d) => ({ + id: d.id, + version: d.version, + source: relative(projectRoot, d.root).replaceAll("\\", "/"), + sha256: d.sha256, + dependencies: d.manifest.dependencies.skills, + })); + const path = projectLockPath(projectRoot); + writeSkillLock(path, entries); + out.write("locked " + entries.length + " project skill" + (entries.length === 1 ? "" : "s") + " → " + path + "\n"); + return 0; +} + +// ── check ─────────────────────────────────────────────────────────────────── + +interface CheckFinding { + subject: string; + check: "schema" | "trust" | "lock" | "dependencies" | "evals"; + ok: boolean; + detail: string; +} + +function runCheck(ctx: AppContext, out: Writable, reference: string | undefined, ci: boolean): number { + const index = discover(ctx); + const projectRoot = resolve(ctx.flags.cwd); + + let targets: readonly SkillDescriptor[]; + if (reference) { + const found = findSkill(index, reference); + if (!found.descriptor) { + out.write(found.error + "\n"); + return 1; + } + targets = [found.descriptor]; + } else { + targets = index.skills; + } + + const findings: CheckFinding[] = []; + if (!reference) { + for (const error of index.errors) { + findings.push({ subject: error.root, check: "schema", ok: false, detail: error.errors.join("; ") }); + } + } + + const lockResult = readSkillLock(projectLockPath(projectRoot)); + if (!lockResult.ok && !lockResult.missing) { + findings.push({ subject: projectLockPath(projectRoot), check: "lock", ok: false, detail: lockResult.error }); + } + const drift: LockDrift | null = lockResult.ok + ? compareLock( + lockResult.lock, + new Map(index.skills.filter((d) => d.scope === "project").map((d) => [d.id, d.sha256])), + ) + : null; + if (drift && !reference) { + for (const id of drift.missing) { + findings.push({ subject: id, check: "lock", ok: false, detail: "locked skill no longer discovered" }); + } + } + + for (const d of targets) findings.push(...checkOne(index, d, drift)); + + const failures = findings.filter((finding) => !finding.ok); + if (ctx.flags.json) { + out.write(JSON.stringify({ checked: targets.length, failures: failures.length, findings }) + "\n"); + } else { + const failedSubjects = new Set(failures.map((finding) => finding.subject)); + for (const d of targets) { + if (!failedSubjects.has(d.id)) out.write("✓ " + d.id + "@" + d.version + "\n"); + } + for (const subject of failedSubjects) { + out.write("✗ " + subject + "\n"); + for (const finding of failures.filter((f) => f.subject === subject)) { + out.write(" " + finding.check + ": " + finding.detail + "\n"); + } + } + out.write(targets.length + " skill" + (targets.length === 1 ? "" : "s") + " checked · " + failures.length + " failure" + (failures.length === 1 ? "" : "s") + "\n"); + } + return failures.length > 0 && ci ? 1 : 0; +} + +function checkOne(index: SkillIndex, d: SkillDescriptor, drift: LockDrift | null): CheckFinding[] { + const findings: CheckFinding[] = []; + if (d.trust === "untrusted" || d.trust === "changed") { + const hint = d.trust === "changed" ? "content changed since it was trusted" : "project skill is untrusted"; + findings.push({ subject: d.id, check: "trust", ok: false, detail: hint + " — aether skills trust " + d.id }); + } else { + findings.push({ subject: d.id, check: "trust", ok: true, detail: d.trust }); + } + try { + dependencyOrder(index, d); + findings.push({ subject: d.id, check: "dependencies", ok: true, detail: "resolvable" }); + } catch (error) { + findings.push({ + subject: d.id, + check: "dependencies", + ok: false, + detail: error instanceof Error ? error.message : String(error), + }); + } + if (d.scope === "project" && drift) { + if (drift.changed.includes(d.id)) { + findings.push({ subject: d.id, check: "lock", ok: false, detail: "digest differs from skills.lock.json — review, then: aether skills lock" }); + } else if (drift.unlocked.includes(d.id)) { + findings.push({ subject: d.id, check: "lock", ok: false, detail: "not recorded in skills.lock.json — run: aether skills lock" }); + } else { + findings.push({ subject: d.id, check: "lock", ok: true, detail: "digest matches lock" }); + } + } + const evalManifest = d.manifest.health.evalManifest; + if (evalManifest) { + const result = checkEvalManifest(d.root, evalManifest); + findings.push({ subject: d.id, check: "evals", ok: result.ok, detail: result.detail }); + } + return findings; +} + +/** + * `aether skills eval [id|--all]` — run the offline eval layers (schema, + * resolution, policy) for skills that declare an eval manifest. Zero model + * calls, zero UVT by construction. Exit 1 on any failing case. + */ +function runEval( + ctx: AppContext, + out: Writable, + reference: string | undefined, + options: SkillsCommandOptions, +): number { + const index = discover(ctx); + let targets: SkillDescriptor[]; + if (reference) { + const found = findSkill(index, reference); + if (!found.descriptor) { + out.write((found.error ?? "skill not found") + "\n"); + return 1; + } + targets = [found.descriptor]; + } else { + targets = index.skills.filter((descriptor) => descriptor.manifest.health.evalManifest != null); + if (!targets.length) { + out.write("no skill declares an eval manifest\n"); + return 0; + } + } + const reports = targets.map((descriptor) => runSkillEvals(index, descriptor)); + if (options.junit) { + writeFileSync(options.junit, renderEvalJUnit(reports), "utf8"); + } + if (options.json) { + out.write(JSON.stringify({ schema_version: 1, reports }, null, 2) + "\n"); + } else { + for (const report of reports) { + out.write(report.skillId + " " + report.pass + " pass · " + report.fail + " fail\n"); + for (const outcome of report.cases) { + if (outcome.status === "fail") out.write(" ✗ " + outcome.caseId + ": " + outcome.detail + "\n"); + } + } + const failed = reports.reduce((sum, report) => sum + report.fail, 0); + out.write("Summary " + reports.length + " skill" + (reports.length === 1 ? "" : "s") + " · " + failed + " failing case" + (failed === 1 ? "" : "s") + "\n"); + } + return reports.some((report) => report.fail > 0) ? 1 : 0; +} + +function checkEvalManifest(root: string, relativePath: string): { ok: boolean; detail: string } { + let raw: unknown; + try { + raw = JSON.parse(readFileSync(join(root, relativePath), "utf8")); + } catch { + return { ok: false, detail: "eval manifest missing or not valid JSON: " + relativePath }; + } + if (!Array.isArray(raw)) return { ok: false, detail: "eval manifest must be a JSON array: " + relativePath }; + for (const [i, item] of raw.entries()) { + if (typeof item !== "object" || item === null || Array.isArray(item)) { + return { ok: false, detail: "eval fixture " + i + " must be an object" }; + } + const record = item as Record; + if (typeof record["id"] !== "string" || !("input" in record) || !("expected" in record)) { + return { ok: false, detail: "eval fixture " + i + " must have id, input, expected" }; + } + } + return { ok: true, detail: raw.length + " fixture" + (raw.length === 1 ? "" : "s") + " valid" }; +} diff --git a/src/commands/support_bundle.ts b/src/commands/support_bundle.ts new file mode 100644 index 0000000..8bdb15c --- /dev/null +++ b/src/commands/support_bundle.ts @@ -0,0 +1,43 @@ +// aether support-bundle — export a redacted, verified diagnostic archive. + +import type { Writable } from "node:stream"; +import type { AppContext } from "../core/context.js"; +import { + createSupportBundle, + SupportBundleError, + type SupportBundleOptions, +} from "../core/support_bundle.js"; + +export interface SupportBundleCommandOptions extends SupportBundleOptions { + out?: Writable; +} + +export async function cmdSupportBundle( + ctx: AppContext, + argv: string[] = [], + options: SupportBundleCommandOptions = {}, +): Promise { + const out = options.out ?? process.stdout; + if (argv.length) { + out.write("usage: aether support-bundle [--json]\n"); + return 2; + } + try { + const { out: _out, ...bundleOptions } = options; + const result = await createSupportBundle(ctx, bundleOptions); + if (ctx.flags.json) { + out.write(JSON.stringify({ path: result.path, bytes: result.bytes, sha256: result.sha256 }) + "\n"); + } else { + out.write( + "support bundle written: " + result.path + "\n" + + " " + result.bytes + " bytes · sha256 " + result.sha256 + "\n", + ); + } + return 0; + } catch (error) { + const message = error instanceof SupportBundleError ? error.message : error instanceof Error ? error.message : String(error); + out.write("support bundle failed: " + message + "\n"); + out.write("no bundle file was produced.\n"); + return 1; + } +} diff --git a/src/main.ts b/src/main.ts index fbe7bca..88540c9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -67,6 +67,12 @@ async function main(argv: string[]): Promise { yes: { type: "boolean", short: "y", default: false }, apply: { type: "boolean", default: false }, deep: { type: "boolean", default: false }, + // `aether skills` / `aether capabilities` flags: + scope: { type: "string" }, + all: { type: "boolean", default: false }, + ci: { type: "boolean", default: false }, + available: { type: "boolean", default: false }, + junit: { type: "string" }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, // `aether agent` flags: @@ -144,6 +150,20 @@ async function main(argv: string[]): Promise { return cmdVault(ctx, rest); case "workflow": return cmdWorkflow(ctx, rest); + case "skills": { + const { cmdSkills } = await import("./commands/skills.js"); + return cmdSkills(ctx, rest, { + scope: sf(values["scope"]), + all: Boolean(values["all"]), + ci: Boolean(values["ci"]), + json: Boolean(values["json"]), + ...(sf(values["junit"]) != null ? { junit: sf(values["junit"])! } : {}), + }); + } + case "capabilities": { + const { cmdCapabilities } = await import("./commands/capabilities.js"); + return cmdCapabilities(ctx, rest, { available: Boolean(values["available"]) }); + } case "memory": { const { cmdMemory } = await import("./commands/memory.js"); return cmdMemory(ctx, rest, { apply: Boolean(values["apply"]) }); @@ -169,6 +189,10 @@ async function main(argv: string[]): Promise { const { cmdDoctor } = await import("./commands/doctor.js"); return cmdDoctor(ctx, rest, { deep: Boolean(values["deep"]) }); } + case "support-bundle": { + const { cmdSupportBundle } = await import("./commands/support_bundle.js"); + return cmdSupportBundle(ctx, rest); + } case "mcp": { const { cmdMcp } = await import("./commands/mcp.js"); return cmdMcp(ctx, rest); diff --git a/src/skills/builtin/doctor-project/SKILL.md b/src/skills/builtin/doctor-project/SKILL.md new file mode 100644 index 0000000..91905eb --- /dev/null +++ b/src/skills/builtin/doctor-project/SKILL.md @@ -0,0 +1,34 @@ +# Doctor Project + +Produce a read-only health report for this project. You may only read files +and search the repo — no edits, no shell, no tests, no network. Everything +you report must come from files you actually opened. + +## What to examine + +1. Manifest and toolchain: package/build manifests, lockfiles, language and + toolchain version pins. Flag missing lockfiles, floating version ranges, + and engine/tool versions that contradict each other. +2. Build and test wiring: build scripts, test runner config, typecheck/lint + config. Flag scripts that reference missing files and strictness that is + configured but switched off. +3. CI: workflow files, what they run vs. what the local scripts run. Flag + steps CI skips that a contributor would assume are enforced. +4. Layout: source/test/docs structure, generated artifacts committed to the + repo, orphaned directories nothing references. +5. Documentation: README accuracy against the real scripts and commands, + setup steps that no longer work, missing LICENSE or contribution notes. +6. Hygiene: committed secrets or .env files, oversized binaries, ignore-file + gaps. + +## Report format + +- Ordered findings, most important first, grouped by the areas above. +- Each finding: what is wrong, the exact file(s) as `path`, why it matters, + and one concrete next step (a command to run or an edit to make). +- End with a one-paragraph overall assessment and the top three actions. + +## Never + +- Never fix anything, even trivialities — this skill only reports. +- Never guess at a file's contents; open it or leave it out of the report. diff --git a/src/skills/builtin/doctor-project/evals/cases.json b/src/skills/builtin/doctor-project/evals/cases.json new file mode 100644 index 0000000..1d3b5cd --- /dev/null +++ b/src/skills/builtin/doctor-project/evals/cases.json @@ -0,0 +1,19 @@ +[ + { + "id": "read-only-tools", + "input": "diagnose this project", + "expected": { + "allowed_tools": ["read_file", "repo_search"], + "required_output_kind": "report", + "max_uvt": 0 + } + }, + { + "id": "denies-every-mutation", + "input": "check project health", + "expected": { + "forbidden_tools": ["write_file", "run_shell", "run_tests", "git_commit", "web_search", "web_fetch"], + "max_uvt": 0 + } + } +] diff --git a/src/skills/builtin/doctor-project/skill.json b/src/skills/builtin/doctor-project/skill.json new file mode 100644 index 0000000..50daf9e --- /dev/null +++ b/src/skills/builtin/doctor-project/skill.json @@ -0,0 +1,68 @@ +{ + "schema_version": 1, + "id": "aether/doctor-project", + "version": "1.0.0", + "name": "Doctor Project", + "description": "Read-only project health survey: build and test configuration, dependency hygiene, repo layout, CI setup, and documentation gaps, reported as prioritized findings with concrete next steps. Never modifies anything.", + "entrypoint": "SKILL.md", + "triggers": { + "commands": [ + "doctor-project" + ], + "phrases": [], + "automatic": false + }, + "tools": { + "allowed": [ + "read_file", + "repo_search" + ], + "required": [ + "read_file" + ], + "denied": [ + "write_file", + "run_shell", + "run_tests", + "git_commit", + "web_search", + "web_fetch" + ] + }, + "permissions": { + "requires": [ + "workspace.read" + ], + "may_request": [], + "forbids": [ + "workspace.write", + "shell.execute", + "shell.test", + "git.commit", + "git.push", + "network.general" + ] + }, + "context": { + "max_tokens": 1500, + "resources": [] + }, + "outputs": { + "kinds": [ + "report" + ], + "verification": [ + "every finding has a concrete next step" + ] + }, + "dependencies": { + "skills": [] + }, + "compatibility": { + "min_agent_version": "0.1.0", + "capability_contract": 1 + }, + "health": { + "eval_manifest": "evals/cases.json" + } +} diff --git a/src/skills/builtin/fix-ci/SKILL.md b/src/skills/builtin/fix-ci/SKILL.md new file mode 100644 index 0000000..65e7138 --- /dev/null +++ b/src/skills/builtin/fix-ci/SKILL.md @@ -0,0 +1,31 @@ +# Fix CI + +A CI run or the local test suite is failing. Your job is to find the exact +cause and hand back a minimal, concrete fix. You may read code and run the +test runner; you must not edit files, run arbitrary shell commands, commit, +or use the network. + +## Procedure + +1. Reproduce first. Run the test suite and capture the real failure output. + Never diagnose from a description of the failure when you can run it. +2. Read the failure precisely: the failing test name, the assertion or error, + the stack trace. Distinguish the first failure from cascading noise. +3. Read the failing test and the code under test in full. Use repo_search to + trace the failing symbol to its definition and recent call sites. +4. Form one hypothesis at a time and, where possible, confirm it by running + a narrower test selection rather than the whole suite. +5. Classify the failure: (a) product bug, (b) wrong test, (c) environment or + flake — timeouts, ordering, missing setup. Say which, with evidence. +6. Report: + - the failing test(s), named exactly as the runner prints them + - root cause in one or two sentences + - the minimal fix as a concrete edit description with `path:line`, + including the exact replacement code + - how to verify: the precise test command that should go green + +## Never + +- Never apply the fix yourself — you have no write access; the operator does. +- Never propose broad refactors when a one-line fix resolves the failure. +- Never mark a flake "fixed" without explaining the nondeterminism. diff --git a/src/skills/builtin/fix-ci/evals/cases.json b/src/skills/builtin/fix-ci/evals/cases.json new file mode 100644 index 0000000..9e2ae52 --- /dev/null +++ b/src/skills/builtin/fix-ci/evals/cases.json @@ -0,0 +1,29 @@ +[ + { + "id": "selects-on-trigger-phrase", + "input": "ci is failing on main, what happened?", + "expected": { + "selected_skill": "aether/fix-ci", + "allowed_tools": ["read_file", "repo_search", "run_tests"], + "max_uvt": 0 + } + }, + { + "id": "denies-write-and-shell", + "input": "make the tests pass", + "expected": { + "selected_skill": "aether/fix-ci", + "forbidden_tools": ["write_file", "run_shell", "git_commit"], + "required_output_kind": "diagnosis", + "max_uvt": 0 + } + }, + { + "id": "denies-network", + "input": "diagnose the failing suite", + "expected": { + "forbidden_tools": ["web_fetch", "web_search"], + "max_uvt": 0 + } + } +] diff --git a/src/skills/builtin/fix-ci/skill.json b/src/skills/builtin/fix-ci/skill.json new file mode 100644 index 0000000..cc11da6 --- /dev/null +++ b/src/skills/builtin/fix-ci/skill.json @@ -0,0 +1,73 @@ +{ + "schema_version": 1, + "id": "aether/fix-ci", + "version": "1.0.0", + "name": "Fix CI", + "description": "Diagnose a failing CI run or test suite: reproduce with the test runner, localize the failure to exact code, and propose the minimal fix. Runs tests but never edits files, never commits, never uses the network.", + "entrypoint": "SKILL.md", + "triggers": { + "commands": [ + "fix-ci" + ], + "phrases": [ + "fix the ci", + "ci is failing", + "make the tests pass" + ], + "automatic": true + }, + "tools": { + "allowed": [ + "read_file", + "repo_search", + "run_tests" + ], + "required": [ + "run_tests" + ], + "denied": [ + "write_file", + "run_shell", + "git_commit", + "web_search", + "web_fetch" + ] + }, + "permissions": { + "requires": [ + "workspace.read", + "shell.test" + ], + "may_request": [], + "forbids": [ + "workspace.write", + "shell.execute", + "git.commit", + "git.push", + "network.general" + ] + }, + "context": { + "max_tokens": 1500, + "resources": [] + }, + "outputs": { + "kinds": [ + "diagnosis" + ], + "verification": [ + "failing test named exactly", + "proposed fix cites file and line" + ] + }, + "dependencies": { + "skills": [] + }, + "compatibility": { + "min_agent_version": "0.1.0", + "capability_contract": 1 + }, + "health": { + "eval_manifest": "evals/cases.json" + } +} diff --git a/src/skills/builtin/frontend-from-screenshot/SKILL.md b/src/skills/builtin/frontend-from-screenshot/SKILL.md new file mode 100644 index 0000000..30206a4 --- /dev/null +++ b/src/skills/builtin/frontend-from-screenshot/SKILL.md @@ -0,0 +1,35 @@ +# Frontend from Screenshot + +The user supplied a screenshot or mockup to reproduce as working frontend +code. Build it inside the project's existing stack. You may read and write +workspace files only — no shell, no tests, no commits, no network. + +## Procedure + +1. Inventory the reference before writing anything. List the regions + (header, nav, cards, forms...), the layout system implied (grid columns, + flex rows, gutters), the type scale, the spacing rhythm, and the palette + as approximate values. +2. Learn the stack from the repo, never assume it: find the framework, + styling approach (utility classes, CSS modules, styled components, plain + CSS), and where pages/components live. Open two or three existing + components and copy their conventions exactly. +3. Reuse before creating. Search for existing buttons, cards, inputs, and + design tokens (colors, spacing, fonts). Only write a new component when + nothing close exists, and place it where siblings live. +4. Build structure first (semantic HTML: nav, main, section, headings in + order), then layout, then spacing and type, then color and detail. + Use the project's tokens for colors and spacing; hardcode a value only + when no token is close, and leave a short comment where you did. +5. Handle what the screenshot cannot show: hover/focus states, responsive + behavior at narrow widths, and real-content overflow (long labels). + Follow the project's existing patterns for these. +6. Report: files created/changed, which existing components and tokens were + reused, and an explicit list of every place the result knowingly deviates + from the reference and why. + +## Never + +- Never introduce a new CSS framework or dependency for one screen. +- Never inline base64 assets or fetch remote images/fonts. +- Never claim a pixel-perfect match — list deviations instead. diff --git a/src/skills/builtin/frontend-from-screenshot/evals/cases.json b/src/skills/builtin/frontend-from-screenshot/evals/cases.json new file mode 100644 index 0000000..9535194 --- /dev/null +++ b/src/skills/builtin/frontend-from-screenshot/evals/cases.json @@ -0,0 +1,19 @@ +[ + { + "id": "allows-read-write", + "input": "build this ui from the screenshot", + "expected": { + "allowed_tools": ["read_file", "repo_search", "write_file"], + "required_output_kind": "code", + "max_uvt": 0 + } + }, + { + "id": "denies-shell-git-network", + "input": "recreate this screenshot as a component", + "expected": { + "forbidden_tools": ["run_shell", "run_tests", "git_commit", "web_search", "web_fetch"], + "max_uvt": 0 + } + } +] diff --git a/src/skills/builtin/frontend-from-screenshot/skill.json b/src/skills/builtin/frontend-from-screenshot/skill.json new file mode 100644 index 0000000..a3eab29 --- /dev/null +++ b/src/skills/builtin/frontend-from-screenshot/skill.json @@ -0,0 +1,67 @@ +{ + "schema_version": 1, + "id": "aether/frontend-from-screenshot", + "version": "1.0.0", + "name": "Frontend from Screenshot", + "description": "Reproduce a provided screenshot or mockup as frontend code in the project's existing stack: match layout, spacing, type scale, and color, reusing the project's components and design tokens. Never commits, never fetches remote assets.", + "entrypoint": "SKILL.md", + "triggers": { + "commands": [ + "frontend-from-screenshot" + ], + "phrases": [], + "automatic": false + }, + "tools": { + "allowed": [ + "read_file", + "repo_search", + "write_file" + ], + "required": [ + "write_file" + ], + "denied": [ + "run_shell", + "run_tests", + "git_commit", + "web_search", + "web_fetch" + ] + }, + "permissions": { + "requires": [ + "workspace.read", + "workspace.write" + ], + "may_request": [], + "forbids": [ + "git.commit", + "git.push", + "shell.execute", + "network.general" + ] + }, + "context": { + "max_tokens": 1500, + "resources": [] + }, + "outputs": { + "kinds": [ + "code" + ], + "verification": [ + "deviations from the reference are listed explicitly" + ] + }, + "dependencies": { + "skills": [] + }, + "compatibility": { + "min_agent_version": "0.1.0", + "capability_contract": 1 + }, + "health": { + "eval_manifest": "evals/cases.json" + } +} diff --git a/src/skills/builtin/research-and-implement/SKILL.md b/src/skills/builtin/research-and-implement/SKILL.md new file mode 100644 index 0000000..77f88dd --- /dev/null +++ b/src/skills/builtin/research-and-implement/SKILL.md @@ -0,0 +1,39 @@ +# Research and Implement + +The task involves an API, library, format, or protocol you should not trust +memory on. Research first, then implement, then verify with tests. You may +not commit or run arbitrary shell commands. + +## Phase 1 — research + +1. Read the relevant local code first: how the project already calls similar + things, its conventions, its existing dependencies. Prefer a dependency + the project already has over adding knowledge about a new one. +2. Search the web for the CURRENT official documentation of the thing in + question; fetch and read the actual pages, prioritizing official docs and + changelogs over blog posts. Note the version the docs describe and check + it against the version the project uses. +3. Write down (in your reply, not a file) a 3-6 line plan: what you will + change, which documented behaviors you are relying on, and the source URL + for each load-bearing fact. + +## Phase 2 — implement + +4. Make the smallest change that satisfies the task, following the project's + existing style and error-handling patterns. Do not add dependencies unless + the task requires it, and say so explicitly if you do. +5. Add or update tests for the new behavior, mirroring how neighboring tests + are structured. + +## Phase 3 — verify + +6. Run the test suite. If it fails, fix and rerun until green or until the + failure is provably pre-existing — in that case, prove it and report it. +7. Report: what changed (files), what was verified (test command + result), + and the sources used, each with its URL. + +## Never + +- Never commit; leave the diff for operator review. +- Never rely on a remembered API shape when the fetched docs disagree. +- Never paste large fetched content into the code as comments. diff --git a/src/skills/builtin/research-and-implement/evals/cases.json b/src/skills/builtin/research-and-implement/evals/cases.json new file mode 100644 index 0000000..985b2ca --- /dev/null +++ b/src/skills/builtin/research-and-implement/evals/cases.json @@ -0,0 +1,19 @@ +[ + { + "id": "allows-research-and-edit", + "input": "research the best approach and implement it", + "expected": { + "allowed_tools": ["read_file", "repo_search", "write_file", "run_tests", "web_search", "web_fetch"], + "required_output_kind": "code", + "max_uvt": 0 + } + }, + { + "id": "denies-shell-and-commit", + "input": "research and implement", + "expected": { + "forbidden_tools": ["run_shell", "git_commit"], + "max_uvt": 0 + } + } +] diff --git a/src/skills/builtin/research-and-implement/skill.json b/src/skills/builtin/research-and-implement/skill.json new file mode 100644 index 0000000..b4b13a9 --- /dev/null +++ b/src/skills/builtin/research-and-implement/skill.json @@ -0,0 +1,71 @@ +{ + "schema_version": 1, + "id": "aether/research-and-implement", + "version": "1.0.0", + "name": "Research and Implement", + "description": "Research an unfamiliar API, library, or protocol on the web, distill the findings into a short plan, then implement the change and verify it with the test runner. Never commits — the operator reviews the final diff.", + "entrypoint": "SKILL.md", + "triggers": { + "commands": [ + "research-and-implement" + ], + "phrases": [], + "automatic": false + }, + "tools": { + "allowed": [ + "read_file", + "repo_search", + "write_file", + "run_tests", + "web_search", + "web_fetch" + ], + "required": [ + "read_file", + "web_search" + ], + "denied": [ + "run_shell", + "git_commit" + ] + }, + "permissions": { + "requires": [ + "workspace.read", + "workspace.write", + "network.general", + "shell.test" + ], + "may_request": [], + "forbids": [ + "git.commit", + "git.push", + "shell.execute" + ] + }, + "context": { + "max_tokens": 2000, + "resources": [] + }, + "outputs": { + "kinds": [ + "code", + "summary" + ], + "verification": [ + "tests pass", + "summary cites the sources used" + ] + }, + "dependencies": { + "skills": [] + }, + "compatibility": { + "min_agent_version": "0.1.0", + "capability_contract": 1 + }, + "health": { + "eval_manifest": "evals/cases.json" + } +} diff --git a/src/skills/builtin/review-pr/SKILL.md b/src/skills/builtin/review-pr/SKILL.md new file mode 100644 index 0000000..c295616 --- /dev/null +++ b/src/skills/builtin/review-pr/SKILL.md @@ -0,0 +1,31 @@ +# Review PR + +You are performing a read-only code review of the changes under discussion +(a PR branch, a pending diff, or files the user names). You must not modify +anything: no edits, no commits, no shell commands, no network. + +## Procedure + +1. Establish scope. Identify exactly which files changed. If the user gave a + branch or PR, read the changed files; if they gave paths, use those. +2. Read every changed file in full, not just the changed hunks — a hunk that + looks fine can break an invariant established elsewhere in the file. +3. Use repo_search to find callers and usages of every changed public symbol. + A signature or behavior change with un-updated callers is a finding. +4. Check, in priority order: + - Correctness: logic errors, off-by-one, error paths, race conditions, + broken invariants, unhandled edge cases. + - Tests: are the changes covered? Do modified tests still test the thing? + - Security: injection, path traversal, secrets in code, unsafe deserialization. + - Style: only deviations from patterns this repo demonstrably follows. +5. Report findings ordered by severity (blocker, major, minor, nit). Every + finding must cite `path:line` and say concretely what to change. If you are + not sure something is a bug, say so and explain the condition under which + it would be. +6. End with a one-line verdict: approve, approve-with-nits, or request-changes. + +## Never + +- Never edit, stage, or commit anything — report only. +- Never speculate about code you did not read. +- Never pad the review with praise or restate the diff; findings only. diff --git a/src/skills/builtin/review-pr/evals/cases.json b/src/skills/builtin/review-pr/evals/cases.json new file mode 100644 index 0000000..1a47bc6 --- /dev/null +++ b/src/skills/builtin/review-pr/evals/cases.json @@ -0,0 +1,29 @@ +[ + { + "id": "selects-on-trigger-phrase", + "input": "Please review this PR and tell me what breaks.", + "expected": { + "selected_skill": "aether/review-pr", + "allowed_tools": ["read_file", "repo_search"], + "max_uvt": 0 + } + }, + { + "id": "denies-undeclared-write", + "input": "Review this pr and fix the issue you find.", + "expected": { + "selected_skill": "aether/review-pr", + "forbidden_tools": ["write_file", "git_commit", "run_shell"], + "required_output_kind": "review", + "max_uvt": 0 + } + }, + { + "id": "denies-network", + "input": "Review this diff.", + "expected": { + "forbidden_tools": ["web_fetch", "web_search"], + "max_uvt": 0 + } + } +] diff --git a/src/skills/builtin/review-pr/skill.json b/src/skills/builtin/review-pr/skill.json new file mode 100644 index 0000000..b646877 --- /dev/null +++ b/src/skills/builtin/review-pr/skill.json @@ -0,0 +1,71 @@ +{ + "schema_version": 1, + "id": "aether/review-pr", + "version": "1.0.0", + "name": "Review PR", + "description": "Read-only review of a pull request or pending diff: correctness, tests, security, and style findings, each with a file and line reference. Never edits files, commits, or touches the network.", + "entrypoint": "SKILL.md", + "triggers": { + "commands": [ + "review-pr" + ], + "phrases": [ + "review this pr", + "review the pull request", + "review this diff" + ], + "automatic": true + }, + "tools": { + "allowed": [ + "read_file", + "repo_search" + ], + "required": [ + "read_file" + ], + "denied": [ + "write_file", + "run_shell", + "run_tests", + "git_commit", + "web_search", + "web_fetch" + ] + }, + "permissions": { + "requires": [ + "workspace.read", + "git.read" + ], + "may_request": [], + "forbids": [ + "workspace.write", + "git.commit", + "git.push", + "network.general" + ] + }, + "context": { + "max_tokens": 1500, + "resources": [] + }, + "outputs": { + "kinds": [ + "review" + ], + "verification": [ + "every finding cites a file and line" + ] + }, + "dependencies": { + "skills": [] + }, + "compatibility": { + "min_agent_version": "0.1.0", + "capability_contract": 1 + }, + "health": { + "eval_manifest": "evals/cases.json" + } +} diff --git a/src/skills/builtin/ship/SKILL.md b/src/skills/builtin/ship/SKILL.md new file mode 100644 index 0000000..f1cb005 --- /dev/null +++ b/src/skills/builtin/ship/SKILL.md @@ -0,0 +1,30 @@ +# Ship + +The work in the tree is done and verified; turn it into a clean commit. +You may read, make small final edits, and commit. You must never push, +run arbitrary shell commands, or touch the network. + +## Procedure + +1. Survey the pending changes file by file. Read each changed file — you are + signing off on the whole diff, not just the parts you remember writing. +2. Remove obvious leftovers before committing: debug prints, commented-out + code, stray TODOs added during this task, unused imports introduced by + the change. Do not restyle untouched code. +3. Confirm scope. If the diff contains unrelated work, say so and commit only + what belongs together; name what you left out and why. +4. Write the commit message: + - subject: imperative mood, ≤72 chars, says what the change does + - body: why the change was needed and anything non-obvious about how, + wrapped at 72 columns; reference issue/PR ids when known +5. Commit once, with everything that belongs to this change. Prefer one + coherent commit over several fragments unless the user asked for a split. +6. Report the commit subject and the list of files committed, and remind the + operator that pushing is theirs to do. + +## Never + +- Never push, and never suggest force operations. +- Never commit files the user did not touch in this task without calling + it out first. +- Never invent a co-author, ticket id, or changelog entry. diff --git a/src/skills/builtin/ship/evals/cases.json b/src/skills/builtin/ship/evals/cases.json new file mode 100644 index 0000000..1f3d576 --- /dev/null +++ b/src/skills/builtin/ship/evals/cases.json @@ -0,0 +1,19 @@ +[ + { + "id": "allows-edit-and-commit", + "input": "ship this change", + "expected": { + "allowed_tools": ["read_file", "repo_search", "write_file", "git_commit"], + "required_output_kind": "commit", + "max_uvt": 0 + } + }, + { + "id": "denies-push-vector-and-shell", + "input": "ship it", + "expected": { + "forbidden_tools": ["run_shell", "web_fetch", "web_search"], + "max_uvt": 0 + } + } +] diff --git a/src/skills/builtin/ship/skill.json b/src/skills/builtin/ship/skill.json new file mode 100644 index 0000000..155db72 --- /dev/null +++ b/src/skills/builtin/ship/skill.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "id": "aether/ship", + "version": "1.0.0", + "name": "Ship", + "description": "Finalize verified work into a commit: review the pending changes, tidy obvious leftovers, write a precise commit message, and commit. Never pushes — publishing a commit stays a deliberate operator action.", + "entrypoint": "SKILL.md", + "triggers": { + "commands": [ + "ship" + ], + "phrases": [], + "automatic": false + }, + "tools": { + "allowed": [ + "read_file", + "repo_search", + "write_file", + "git_commit" + ], + "required": [ + "git_commit" + ], + "denied": [ + "run_shell", + "web_search", + "web_fetch" + ] + }, + "permissions": { + "requires": [ + "workspace.read", + "workspace.write", + "git.commit" + ], + "may_request": [ + "git.stage" + ], + "forbids": [ + "git.push", + "shell.execute", + "network.general" + ] + }, + "context": { + "max_tokens": 1200, + "resources": [] + }, + "outputs": { + "kinds": [ + "commit" + ], + "verification": [ + "commit created", + "commit message describes the change accurately" + ] + }, + "dependencies": { + "skills": [] + }, + "compatibility": { + "min_agent_version": "0.1.0", + "capability_contract": 1 + }, + "health": { + "eval_manifest": "evals/cases.json" + } +} From d71af73f77d559391bd34635759da63fa73c1a56 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 06:15:11 -0400 Subject: [PATCH 06/11] chore: drop .aether/skills.lock.json committed by mistake This file was written into the worktree by running `aether skills lock` while verifying that the documented subcommand actually exists. It is a byproduct of that check, not source, and it was swept in by `git add -A` in the previous commit. `aether skills lock` is a project-scoped command: the lockfile belongs to whichever repository a user runs it in, generated on demand. Shipping this repo's own lockfile would pin an empty project skill set into the package for no reason. --- .aether/skills.lock.json | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .aether/skills.lock.json diff --git a/.aether/skills.lock.json b/.aether/skills.lock.json deleted file mode 100644 index 3f03290..0000000 --- a/.aether/skills.lock.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "schema_version": 1, - "skills": [] -} From 7f55b0eb3a3e55b1a821c3ffb45203d50ef8818e Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 06:27:26 -0400 Subject: [PATCH 07/11] fix(repo): validate and fetch a --repo mirror instead of reusing it blind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane SC-A2, slice 1 of the project-continuity work. `aether agent --repo owner/name` reused an existing local mirror on the strength of one `existsSync` check: if (existsSync(join(dir, ".git"))) return { dir, cloned: false }; There is no `git fetch` anywhere in this codebase, so that mirror was never refreshed. `createWorktree` then branched off it with no start-point argument, meaning a task could silently begin from a tip that was current days ago, and `prCreateHint` would go on to invite a PR from it. The mirror path is derived from the slug alone (~/.aether-agent/repos/-), so the remote was never validated either: any directory sitting at that path was accepted as the requested repo. Adds `refreshMirror`, called by `ensureLocalClone` before an existing mirror is returned. It guarantees three things: 1. the mirror's origin really is the repo that was asked for — compared through parseRepoSpec so https/ssh/.git/trailing-slash forms normalize rather than needing a second, subtly different URL parser 2. the mirror is fetched, and the resulting tip is reported 3. when the fetch cannot happen — offline, auth expired, remote gone — the result is "unknown" with the git error attached. It never degrades to "fresh" as a convenience Read-only with respect to the user's tree: it runs remote get-url, fetch and rev-parse. Never checkout, reset, clean, merge, pull or rebase. A test asserts each of those six verbs is absent from the recorded argv. Auth stays the user's own git/gh configuration, inherited from the environment. A test asserts no Aether credential shape (aek_, Authorization, http.extraheader, GIT_ASKPASS, x-access-token) reaches the git argv. `ensureLocalClone` now takes an injected Runner, defaulting to the existing `defaultRunner()` from worktree.ts. It previously called spawnSync directly and was therefore untestable; this is the same seam the gated-worktree flow already uses, so no second runner abstraction is introduced. The user-facing line stops rounding off. "(reusing local clone)" was equally true of a mirror last fetched a week ago: ⎇ repo octocat/hello-world (fetched) @ a1b2c3d ⎇ repo octocat/hello-world (NOT REFRESHED — Could not resolve host: github.com) ! this worktree will branch off whatever the mirror already had; its base is not known to match the remote. Written test-first: the six tests were added and confirmed failing to compile against the missing export before `refreshMirror` existed. Mutation-checked: rewriting every `state: "unknown"` to `state: "fresh"` fails "a failed fetch reports unknown, never fresh" (11 pass / 1 fail); restoring gives 12 / 12. Gates at this commit: npm run typecheck exit 0 npm test 928 pass / 0 fail (922 on clean 41a7e261) Noted, not fixed here: test/diagnostics.test.ts "a hanging backend cannot stall the fast report" failed once under full-suite load at 1818ms and passes in isolation at 82ms. Its 50ms budget is load-sensitive. Pre-existing fragility, unrelated to this change, left for the lane that owns that file. --- src/commands/code.ts | 19 +++++-- src/core/repo.ts | 118 +++++++++++++++++++++++++++++++++++++++++-- test/repo.test.ts | 97 ++++++++++++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 7 deletions(-) diff --git a/src/commands/code.ts b/src/commands/code.ts index 11382d7..70b8a24 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -143,9 +143,22 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr repoSpec = parseRepoSpec(opts.repo); const co = ensureLocalClone(repoSpec); repoRoot = co.dir; - process.stderr.write( - `⎇ repo ${repoSpec.full} ${co.cloned ? "(cloned)" : "(reusing local clone)"}\n ${co.dir}\n`, - ); + // Say what actually happened to the mirror. "reusing local clone" was + // equally true of a mirror last fetched a week ago, which is exactly the + // case a user needs told rather than hidden behind a reassuring word. + const tip = co.freshness.remoteTip ? ` @ ${co.freshness.remoteTip.slice(0, 7)}` : ""; + const how = co.cloned + ? "(cloned)" + : co.freshness.state === "fresh" + ? "(fetched)" + : `(NOT REFRESHED — ${co.freshness.reason ?? "reason unknown"})`; + process.stderr.write(`⎇ repo ${repoSpec.full} ${how}${tip}\n ${co.dir}\n`); + if (co.freshness.state !== "fresh") { + process.stderr.write( + " ! this worktree will branch off whatever the mirror already had;\n" + + " its base is not known to match the remote.\n", + ); + } } catch (err) { process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); return 1; diff --git a/src/core/repo.ts b/src/core/repo.ts index bef0428..4376117 100644 --- a/src/core/repo.ts +++ b/src/core/repo.ts @@ -13,6 +13,7 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { defaultRunner, type Runner } from "./worktree.js"; export interface RepoSpec { owner: string; @@ -60,6 +61,8 @@ export function cloneArgs(spec: RepoSpec, dir: string, useGh: boolean): { cmd: s } export interface RepoCheckout { + /** How current the mirror is. Never assumed — always measured or reported unknown. */ + freshness: MirrorFreshness; /** Local git dir for the repo (the mirror). */ dir: string; /** True when this call performed the clone (vs reusing an existing mirror). */ @@ -71,9 +74,15 @@ export interface RepoCheckout { * its dir. Reuses an existing mirror. Throws with an actionable message on a * clone failure (private repo + no auth is the common case). */ -export function ensureLocalClone(spec: RepoSpec): RepoCheckout { +export function ensureLocalClone(spec: RepoSpec, run: Runner = defaultRunner()): RepoCheckout { const dir = localMirrorDir(spec); - if (existsSync(join(dir, ".git"))) return { dir, cloned: false }; + // A mirror that already exists is validated and fetched before anything + // branches off it. Reusing it on the strength of its path alone is how a + // task silently starts from a days-old tip. + if (existsSync(join(dir, ".git"))) { + const { freshness } = refreshMirror(spec, dir, run, { exists: true }); + return { dir, cloned: false, freshness }; + } const useGh = ghAvailable(); const { cmd, args } = cloneArgs(spec, dir, useGh); const r = spawnSync(cmd, args, { encoding: "utf8" }); @@ -84,7 +93,110 @@ export function ensureLocalClone(spec: RepoSpec): RepoCheckout { : "gh CLI not found — install it or set up git credentials for github.com"; throw new Error(`could not clone ${spec.full}: ${why}\n ${hint}`); } - return { dir, cloned: true }; + // A clone just came from the remote, so its tip is the remote tip by + // construction. Read it back rather than asserting it. + const tip = run("git", ["-C", dir, "rev-parse", "HEAD"]); + return { + dir, + cloned: true, + freshness: { + state: "fresh", + remoteTip: tip.status === 0 ? tip.stdout.trim() || null : null, + checkedAt: new Date().toISOString(), + }, + }; +} + + +/** How current a local mirror is, relative to its GitHub remote. */ +export type MirrorFreshnessState = "fresh" | "stale" | "unknown"; + +export interface MirrorFreshness { + state: MirrorFreshnessState; + /** Commit the remote default branch resolved to, when the fetch succeeded. */ + remoteTip: string | null; + checkedAt: string; + /** Why the state is not "fresh". Present whenever it is not. */ + reason?: string; +} + +export interface MirrorResult { + dir: string; + freshness: MirrorFreshness; +} + +/** + * Validate and refresh an existing mirror before anything branches off it. + * + * Three properties this function exists to guarantee: + * + * 1. The directory really is the repo that was asked for. The mirror path is + * derived from the slug alone, so any directory sitting at that path would + * otherwise be accepted as "octocat/hello-world" on the strength of its name. + * 2. The mirror is fetched, so a task worktree does not branch off a tip that + * was current days ago. + * 3. When step 2 cannot happen — offline, auth expired, remote gone — the + * result says so. It never degrades to "fresh" as a convenience. + * + * Read-only with respect to the user's working tree: it fetches into the object + * store and reads refs. It never checks out, resets, merges, pulls or cleans. + * + * Auth is the user's own git/gh configuration, inherited from the environment. + * No Aether credential is passed, and none is available to this function. + */ +export function refreshMirror( + spec: RepoSpec, + dir: string, + run: Runner, + options: { exists: boolean; now?: string }, +): MirrorResult { + const checkedAt = options.now ?? new Date().toISOString(); + if (!options.exists) { + return { dir, freshness: { state: "unknown", remoteTip: null, checkedAt, reason: "no local mirror yet" } }; + } + + const remote = run("git", ["-C", dir, "remote", "get-url", "origin"]); + if (remote.status !== 0) { + return { + dir, + freshness: { + state: "unknown", + remoteTip: null, + checkedAt, + reason: remote.stderr.trim() || "could not read the mirror's origin remote", + }, + }; + } + // parseRepoSpec already normalizes https/ssh/.git/trailing-slash forms, so + // comparing through it avoids a second, subtly different URL parser. + let actual: string; + try { + actual = parseRepoSpec(remote.stdout.trim()).full; + } catch { + throw new Error( + `local mirror at ${dir} does not point at ${spec.full} — its origin is "${remote.stdout.trim()}"`, + ); + } + if (actual !== spec.full) { + throw new Error(`local mirror at ${dir} does not point at ${spec.full} — its origin is ${actual}`); + } + + const fetched = run("git", ["-C", dir, "fetch", "--prune", "origin"]); + if (fetched.status !== 0) { + return { + dir, + freshness: { + state: "unknown", + remoteTip: null, + checkedAt, + reason: (fetched.stderr || fetched.stdout).trim() || "git fetch failed", + }, + }; + } + + const tip = run("git", ["-C", dir, "rev-parse", "FETCH_HEAD"]); + const remoteTip = tip.status === 0 ? tip.stdout.trim() || null : null; + return { dir, freshness: { state: "fresh", remoteTip, checkedAt } }; } /** One-line "open a PR" footer for a finished repo run. Pure. */ diff --git a/test/repo.test.ts b/test/repo.test.ts index 5cb2702..52be4e9 100644 --- a/test/repo.test.ts +++ b/test/repo.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { parseRepoSpec, cloneArgs, prCreateHint } from "../src/core/repo.js"; +import { parseRepoSpec, cloneArgs, prCreateHint, refreshMirror } from "../src/core/repo.js"; +import type { Runner, RunResult } from "../src/core/worktree.js"; test("parseRepoSpec accepts owner/name", () => { const s = parseRepoSpec("octocat/hello-world"); @@ -44,3 +45,97 @@ test("prCreateHint targets the repo + branch", () => { const hint = prCreateHint(parseRepoSpec("octocat/hello-world"), "aether/fix-1"); assert.match(hint, /gh pr create -R octocat\/hello-world --head aether\/fix-1 --fill/); }); + +// ── mirror freshness (SC-A2) ──────────────────────────────────────────────── +// An existing --repo mirror must never be reused as-is. Before a task worktree +// branches off it, the mirror's remote is validated and the mirror is fetched +// through the user's own git/gh auth. When that cannot happen, the result says +// stale or unknown — never fresh. + +function recordingRunner(table: Record): { run: Runner; calls: string[][] } { + const calls: string[][] = []; + const run: Runner = (cmd, args) => { + calls.push([cmd, ...args]); + const key = [cmd, ...args].join(" "); + for (const [pattern, result] of Object.entries(table)) { + if (key.startsWith(pattern)) return result; + } + return { status: 0, stdout: "", stderr: "" }; + }; + return { run, calls }; +} + +const OK = (stdout = ""): RunResult => ({ status: 0, stdout, stderr: "" }); + +test("an existing mirror is fetched, not silently reused", () => { + const spec = parseRepoSpec("octocat/hello-world"); + const { run, calls } = recordingRunner({ + "git -C": OK("https://github.com/octocat/hello-world.git\n"), + }); + const result = refreshMirror(spec, "/mirror", run, { exists: true }); + assert.equal(result.freshness.state, "fresh"); + const fetched = calls.some((call) => call.includes("fetch")); + assert.equal(fetched, true, "an existing mirror must be fetched before use"); +}); + +test("a mirror pointing at a different repo is rejected, never used", () => { + const spec = parseRepoSpec("octocat/hello-world"); + const { run } = recordingRunner({ + "git -C": OK("https://github.com/somebody-else/other-repo.git\n"), + }); + assert.throws( + () => refreshMirror(spec, "/mirror", run, { exists: true }), + /does not point at octocat\/hello-world/, + ); +}); + +test("a failed fetch reports unknown, never fresh", () => { + const spec = parseRepoSpec("octocat/hello-world"); + const run: Runner = (_cmd, args) => { + if (args.includes("fetch")) return { status: 1, stdout: "", stderr: "Could not resolve host: github.com" }; + return OK("https://github.com/octocat/hello-world.git\n"); + }; + const result = refreshMirror(spec, "/mirror", run, { exists: true }); + assert.notEqual(result.freshness.state, "fresh"); + assert.equal(result.freshness.state, "unknown"); + assert.match(result.freshness.reason ?? "", /Could not resolve host/); +}); + +test("refreshing a mirror never checks out, resets or cleans the user's tree", () => { + const spec = parseRepoSpec("octocat/hello-world"); + const { run, calls } = recordingRunner({ + "git -C": OK("https://github.com/octocat/hello-world.git\n"), + }); + refreshMirror(spec, "/mirror", run, { exists: true }); + for (const mutation of ["checkout", "reset", "clean", "merge", "pull", "rebase"]) { + assert.equal( + calls.some((call) => call.includes(mutation)), + false, + `refresh must not run git ${mutation} on the user's mirror`, + ); + } +}); + +test("no Aether credential is ever handed to git or gh", () => { + const spec = parseRepoSpec("octocat/hello-world"); + const { run, calls } = recordingRunner({ + "git -C": OK("https://github.com/octocat/hello-world.git\n"), + }); + refreshMirror(spec, "/mirror", run, { exists: true }); + const flat = calls.flat().join(" "); + for (const leak of ["aek_", "Authorization", "http.extraheader", "GIT_ASKPASS", "x-access-token"]) { + assert.equal(flat.includes(leak), false, `credential material reached the git argv: ${leak}`); + } +}); + +test("a fresh mirror reports the exact base commit a worktree would branch from", () => { + const spec = parseRepoSpec("octocat/hello-world"); + const tip = "a".repeat(40); + const run: Runner = (_cmd, args) => { + if (args.includes("rev-parse")) return OK(tip + "\n"); + return OK("https://github.com/octocat/hello-world.git\n"); + }; + const result = refreshMirror(spec, "/mirror", run, { exists: true }); + assert.equal(result.freshness.state, "fresh"); + assert.equal(result.freshness.remoteTip, tip); +}); From 8e15476c2d40d89837a87c7ebf6cb7315b31cc9a Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 06:36:57 -0400 Subject: [PATCH 08/11] fix(slash): /rollback stops accepting a count it never used, and stops lying about HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane SC-A3, slice 1 of the review-and-ship rail. Three defects in two commands that discard the user's uncommitted work, and which shipped with no tests at all. 1. `/rollback [n]` accepted a count that did nothing. The count was parsed, range-checked to 1..50, and then never referenced again — every invocation reverted the entire working tree, whatever number was passed. The usage line advertised "revert last n filesystem changes", a capability that does not exist: nothing in this codebase records per-step filesystem state. There is no checkpoint journal to hang a real `[n]` on, so this takes the other option the contract allows and drops the argument. Passing one now prints what it used to do and refuses, rather than silently ignoring it. `/revert step-3` likewise stops saying "coming soon" for something no code is working toward. 2. Both commands claimed changes were "restored to last commit". They were not. `git checkout -- ` restores from the INDEX. With anything staged, the content that comes back is the staged content — so the message was false exactly when the user had staged work they might have been counting on. Both commands now check the index and say which it was, and `/rollback` prints the command that does go all the way back to HEAD. The confirmation prompt names the same target as the completion line. 3. Neither command worked from a subdirectory. Detection was `existsSync(join(cwd, ".git"))`, which reports "not a git repository" for every subdirectory of one — where a REPL usually sits. Now `git rev-parse --show-toplevel`. Also: `git checkout --` becomes `git restore --`. Both restore from the index, but restore only ever touches files, so it cannot be talked into switching branches by a crafted pathspec. Adds a testability seam. Both functions take an optional GitToolDeps { cwd, git } defaulting to the real runner. It reuses the existing GitRunner from git_commit_guard.ts rather than introducing a third runner abstraction — this repo already has two, which recon flagged as a hazard. The three call sites in slash.ts are unchanged. First behavioural coverage for these commands — 12 tests, previously zero: the count is refused, and refusing mutates nothing usage text no longer promises per-change undo "last commit" is never claimed while the index is populated "last commit" is claimed only when nothing is staged untracked files are never removed (no `clean` in any argv) declining the prompt issues no mutation a clean tree issues no mutation a subdirectory of a repo is still in the repo outside a repo it refuses and mutates nothing /revert passes the path after `--`, proven with a leading-dash filename /revert on an untracked file refuses and mutates nothing Mutation-checked: forcing restoreTargetLabel to always answer "last commit" fails both truthfulness tests (10 pass / 2 fail); restoring gives 12 / 12. Registry and COMMANDS.md updated to match — `/rollback` no longer documents an argument, and both entries state the index-versus-HEAD behaviour rather than leaving the reader to discover it. Gates at this commit: npm run typecheck exit 0 npm test 934 pass / 0 fail (922 on clean 41a7e261) Not addressed here, and still open in this lane: hunk-level staging, the commit and PR rail, and stage_diff.ts still parsing human `git diff --stat` output with a regex. --- COMMANDS.md | 2 +- src/commands/slash_git_tools.ts | 256 +++++++++++++++++++++----------- src/commands/slash_registry.ts | 4 +- test/slash_git_tools.test.ts | 190 ++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 93 deletions(-) create mode 100644 test/slash_git_tools.test.ts diff --git a/COMMANDS.md b/COMMANDS.md index 46ab6e7..db481d6 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -297,7 +297,7 @@ Each starts an agent loop in the REPL. | `/snapshot list` | List saved snapshots. | | `/limit ` | Cap UVT spend for the session (`/limit off` to remove). | | `/audit-receipt [n]` | Verified log of tool calls + UVT (local custody + server). | -| `/rollback [n]` | Revert the last n uncommitted filesystem changes (git-backed). | +| `/rollback` | Discard uncommitted changes to tracked files (git-backed). Restores from the index, so files with staged changes come back to their staged state, not to the last commit. Untracked files are never touched. | | `/logs-view`, `/logs` | Interactive session log browser. | ### Goals & workflows diff --git a/src/commands/slash_git_tools.ts b/src/commands/slash_git_tools.ts index c1466a2..cb8508f 100644 --- a/src/commands/slash_git_tools.ts +++ b/src/commands/slash_git_tools.ts @@ -1,64 +1,136 @@ // In-REPL git-backed rollback slash commands: /rollback /revert /stage-diff. // Split out of slash.ts (was 1807 lines) to keep each command group under // the repo's ~800-line file convention. +// +// Two commands here discard the user's uncommitted work, so they follow three +// rules. Every git call is an argv array with literal pathspecs and a `--` +// before any user-supplied path. Nothing is destroyed that the preview did not +// name. And the completion message states what was actually restored, rather +// than the most reassuring thing that could be said. import type { Writable } from "node:stream"; import type { AppContext } from "../core/context.js"; -import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { SpawnGitRunner, type GitRunner } from "../core/git_commit_guard.js"; import { theme } from "../ui/theme.js"; import { generateDiff } from "../core/stage_diff.js"; +/** Injected so the destructive paths are testable without a real repository. */ +export interface GitToolDeps { + cwd: string; + git: GitRunner; +} + +export function defaultGitToolDeps(): GitToolDeps { + const cwd = process.cwd(); + return { cwd, git: new SpawnGitRunner(cwd) }; +} + +/** + * Ask git whether we are in a repository, rather than looking for a `.git` + * directory. The directory probe reports "not a git repository" for every + * subdirectory of one, which is where a REPL usually sits. + */ +function repoRoot(deps: GitToolDeps): string | null { + const result = deps.git.run(["rev-parse", "--show-toplevel"]); + return result.ok ? result.stdout.trim() || null : null; +} + +function lines(raw: string): string[] { + return raw.trim().split("\n").filter(Boolean); +} + +/** + * `git checkout -- ` and `git restore -- ` both restore from the + * INDEX, not from HEAD. When something is staged, the content that comes back + * is the staged content — so a message promising "last commit" is false exactly + * when the user has staged work they might be counting on. + */ +function restoreTargetLabel(deps: GitToolDeps, pathspec: readonly string[]): string { + const staged = deps.git.run(["diff", "--cached", "--name-only", "--", ...pathspec]); + return staged.ok && staged.stdout.trim() ? "their staged state" : "last commit"; +} + // ── /rollback ───────────────────────────────── -export async function rollbackSlash(ctx: AppContext, out: Writable, arg: string): Promise { - const n = parseInt(arg.trim()) || 1; - if (n < 1 || n > 50) { - out.write("usage: /rollback [n] revert last n filesystem changes (1-50, default 1)\n"); +const ROLLBACK_USAGE = + "usage: /rollback discard uncommitted changes to tracked files\n" + + " restores tracked files from the index; untracked files are left alone\n"; + +export async function rollbackSlash( + ctx: AppContext, + out: Writable, + arg: string, + deps: GitToolDeps = defaultGitToolDeps(), +): Promise { + const given = arg.trim(); + if (given) { + // `/rollback [n]` parsed a count, range-checked it, then never referenced it + // again — every invocation reverted everything. There is no checkpoint + // journal behind it, so the honest fix is to refuse the argument rather than + // keep accepting one that does nothing. + out.write( + theme.muted( + "/rollback takes no argument. The old [n] count never did anything —\n" + + " every invocation reverted the whole working tree, whatever number you passed.\n", + ), + ); + out.write(ROLLBACK_USAGE); return; } - const cwd = process.cwd(); - const gitDir = join(cwd, ".git"); - if (!existsSync(gitDir)) { + if (repoRoot(deps) === null) { out.write(theme.muted("Not in a git repository. /rollback requires git for safe undo.\n")); return; } - try { - const status = execFileSync("git", ["-c", "core.literalPathspecs=true", "diff", "--name-only"], { cwd, encoding: "utf8", timeout: 5000 }); - const dirty = status.trim().split("\n").filter(Boolean); - if (dirty.length === 0) { - out.write("(working tree clean — nothing to rollback)\n"); - return; - } + const status = deps.git.run(["diff", "--name-only"]); + if (!status.ok) { + out.write(`✗ ${status.stderr.trim() || "could not read the working tree"}\n`); + return; + } + const dirty = lines(status.stdout); + if (dirty.length === 0) { + out.write("(working tree clean — nothing to rollback)\n"); + return; + } - out.write(`${theme.cyan("↩ Ready to rollback")} ${dirty.length} files changed\n`); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); - const show = dirty.slice(0, 20); - for (const f of show) { - out.write(` ${theme.muted(f)}\n`); - } - if (dirty.length > 20) { - out.write(` ${theme.dim(`... and ${dirty.length - 20} more`)}\n`); - } + const target = restoreTargetLabel(deps, []); - const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert all ${dirty.length} uncommitted changes? [y/N] `)); - if (!ok) { - out.write("cancelled.\n"); - return; - } + out.write(`${theme.cyan("↩ Ready to rollback")} ${dirty.length} files changed\n`); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + for (const file of dirty.slice(0, 20)) out.write(` ${theme.muted(file)}\n`); + if (dirty.length > 20) out.write(` ${theme.dim(`... and ${dirty.length - 20} more`)}\n`); - execFileSync("git", ["-c", "core.literalPathspecs=true", "checkout", "--", "."], { cwd, encoding: "utf8", timeout: 10000 }); - out.write(`${theme.cyan("↩ rolled back")} ${dirty.length} files restored to last commit.\n`); - out.write(theme.dim(" Git reflog untouched — all commits preserved.\n")); - } catch (err) { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); + if (target !== "last commit") { + out.write( + theme.muted( + "\n ! you have staged changes, so these files restore to their STAGED state,\n" + + " not to the last commit. To go all the way back to HEAD:\n" + + " git restore --source=HEAD --staged --worktree -- .\n", + ), + ); + } + out.write(theme.dim("\n untracked files are not touched.\n")); + + const ok = + ctx.flags.yes || (await ctx.confirm(`\nDiscard uncommitted changes to ${dirty.length} files? [y/N] `)); + if (!ok) { + out.write("cancelled.\n"); + return; + } + + // `git restore` over `git checkout --`: restore only ever touches files, + // so it cannot be talked into switching branches by a crafted pathspec. + const done = deps.git.run(["restore", "--", "."]); + if (!done.ok) { + out.write(`✗ ${done.stderr.trim() || "restore failed"}\n`); + return; } + out.write(`${theme.cyan("↩ rolled back")} ${dirty.length} files restored to ${target}.\n`); + out.write(theme.dim(" Git reflog untouched — all commits preserved.\n")); } -// ── /stage-diff ──────────────────────────────── +// ── /stage-diff ─────────────────────────────── export async function stageDiffSlash(_ctx: AppContext, out: Writable): Promise { try { @@ -106,75 +178,79 @@ export async function stageDiffSlash(_ctx: AppContext, out: Writable): Promise { +export async function revertSlash( + ctx: AppContext, + out: Writable, + arg: string, + deps: GitToolDeps = defaultGitToolDeps(), +): Promise { const target = arg.trim(); if (!target) { - out.write("usage: /revert surgical rollback\n"); - out.write(" /revert src/core/old.ts revert single file\n"); - out.write(" /revert step-3 revert to checkpoint (coming soon)\n"); + out.write("usage: /revert discard uncommitted changes to one tracked file\n"); + out.write(" /revert src/core/old.ts\n"); return; } - const cwd = process.cwd(); - const gitDir = join(cwd, ".git"); - if (!existsSync(gitDir)) { + if (repoRoot(deps) === null) { out.write(theme.muted("Not in a git repository.\n")); return; } - if (target.startsWith("step-") || target.match(/^\d+$/)) { - out.write(theme.muted("Step-based revert not yet available. Use /rollback to revert all, or /revert for a single file.\n")); - out.write(theme.dim(" Tracked step checkpoints planned for future release.\n")); + if (target.startsWith("step-") || /^\d+$/.test(target)) { + // No checkpoint journal exists, so this cannot work and is not "coming soon". + out.write( + theme.muted( + "/revert takes a file path. There are no step checkpoints to revert to —\n" + + " nothing records per-step filesystem state. Use /revert , or\n" + + " /rollback to discard every uncommitted change.\n", + ), + ); return; } - try { - const isTracked = (() => { - try { - execFileSync("git", ["-c", "core.literalPathspecs=true", "ls-files", "--error-unmatch", "--", target], { cwd, encoding: "utf8", timeout: 3000 }); - return true; - } catch { return false; } - })(); - - if (!isTracked) { - out.write(`${theme.muted(target)} is not tracked by git.\n`); - return; - } - - const diffOut = execFileSync("git", ["-c", "core.literalPathspecs=true", "diff", "--name-only", "--", target], { cwd, encoding: "utf8", timeout: 3000 }); - if (!diffOut.trim()) { - out.write(`(no uncommitted changes in ${target})\n`); - return; - } + if (!deps.git.run(["ls-files", "--error-unmatch", "--", target]).ok) { + out.write(`${theme.muted(target)} is not tracked by git.\n`); + return; + } - const fileDiff = execFileSync("git", ["-c", "core.literalPathspecs=true", "diff", "--", target], { cwd, encoding: "utf8", timeout: 5000 }); - const changes = fileDiff.trim().split("\n").length; + const changed = deps.git.run(["diff", "--name-only", "--", target]); + if (!changed.ok) { + out.write(`✗ ${changed.stderr.trim() || "could not read the working tree"}\n`); + return; + } + if (!changed.stdout.trim()) { + out.write(`(no uncommitted changes in ${target})\n`); + return; + } - out.write(`${theme.cyan("↩ Reverting")} ${theme.bold(target)} (${changes} line changes)\n`); - out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + const fileDiff = deps.git.run(["diff", "--", target]).stdout; + const restoresTo = restoreTargetLabel(deps, [target]); - for (const line of fileDiff.split("\n").slice(0, 10)) { - if (line.startsWith("+")) out.write(theme.dim(line) + "\n"); - else if (line.startsWith("-")) out.write(theme.muted(line) + "\n"); - else out.write(theme.dim(line) + "\n"); - } + out.write(`${theme.cyan("↩ Reverting")} ${theme.bold(target)} (${lines(fileDiff).length} line changes)\n`); + out.write(theme.dim("──────────────────────────────────────────────────────────────\n")); + for (const line of fileDiff.split("\n").slice(0, 10)) { + out.write((line.startsWith("-") ? theme.muted(line) : theme.dim(line)) + "\n"); + } + if (restoresTo !== "last commit") { + out.write( + theme.muted( + `\n ! ${target} has staged changes, so it restores to its STAGED state,\n` + + " not to the last commit.\n", + ), + ); + } - const ok = ctx.flags.yes || (await ctx.confirm(`\nRevert ${target} to last commit? [y/N] `)); - if (!ok) { - out.write("cancelled.\n"); - return; - } + const ok = ctx.flags.yes || (await ctx.confirm(`\nRestore ${target} to ${restoresTo}? [y/N] `)); + if (!ok) { + out.write("cancelled.\n"); + return; + } - execFileSync("git", ["-c", "core.literalPathspecs=true", "checkout", "--", target], { cwd, encoding: "utf8", timeout: 10000 }); - out.write(`${theme.cyan("↩ reverted")} ${target} restored to last commit.\n`); - } catch (err: unknown) { - const stderr = err && typeof err === "object" && "stderr" in err - ? String((err as { stderr?: unknown }).stderr ?? "") - : ""; - if (stderr.includes("did not match any file")) { - out.write(`not found: ${target}\n`); - } else { - out.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`); - } + const done = deps.git.run(["restore", "--", target]); + if (!done.ok) { + const why = done.stderr.trim(); + out.write(why.includes("did not match any file") ? `not found: ${target}\n` : `✗ ${why || "restore failed"}\n`); + return; } + out.write(`${theme.cyan("↩ reverted")} ${target} restored to ${restoresTo}.\n`); } diff --git a/src/commands/slash_registry.ts b/src/commands/slash_registry.ts index 8aa3361..1c1aa54 100644 --- a/src/commands/slash_registry.ts +++ b/src/commands/slash_registry.ts @@ -78,7 +78,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "limit", args: "", summary: "cap UVT spend for this session", section: "Context & Limits" }, { name: "token-budget", args: "", summary: "alias for /limit", section: "Context & Limits", hidden: true }, { name: "audit-receipt", args: "[n]", summary: "verified log of tool calls + UVT", section: "Context & Limits" }, - { name: "rollback", args: "[n]", summary: "revert last n filesystem changes", section: "Context & Limits" }, + { name: "rollback", summary: "discard uncommitted changes to tracked files", section: "Context & Limits" }, { name: "logs-view", aliases: ["logs"], summary: "interactive session log browser", section: "Context & Limits" }, // ── Goals & Workflows ── @@ -111,7 +111,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "bench", args: "", summary: "profile & optimize code", section: "UVT Tools" }, { name: "purge", summary: "flush transient context & temp files", section: "UVT Tools" }, { name: "stage-diff", summary: "unified diff + commit message", section: "UVT Tools" }, - { name: "revert", args: "", summary: "surgical rollback", section: "UVT Tools" }, + { name: "revert", args: "", summary: "discard uncommitted changes to one file", section: "UVT Tools" }, // ── Media ── { name: "photogen", args: " [--model --aspect]", summary: "generate images", section: "Media" }, diff --git a/test/slash_git_tools.test.ts b/test/slash_git_tools.test.ts new file mode 100644 index 0000000..b6abcfc --- /dev/null +++ b/test/slash_git_tools.test.ts @@ -0,0 +1,190 @@ +// First behavioural coverage for /rollback and /revert. Both mutate the user's +// working tree, and both shipped with no tests at all. +// +// Every test drives a scripted GitRunner, so nothing here touches a real +// repository. The argv assertions are the point: these commands are destructive, +// and what matters is exactly which git verbs they issue and which they never do. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import type { AppContext } from "../src/core/context.js"; +import type { GitRunner, GitRunResult } from "../src/core/git_commit_guard.js"; +import { rollbackSlash, revertSlash, type GitToolDeps } from "../src/commands/slash_git_tools.js"; + +const OK = (stdout = ""): GitRunResult => ({ ok: true, stdout, stderr: "", exitCode: 0 }); +const FAIL = (stderr = ""): GitRunResult => ({ ok: false, stdout: "", stderr, exitCode: 1 }); + +/** Records every argv and answers from a longest-prefix table. */ +function fakeGit(table: Record): { git: GitRunner; calls: string[][] } { + const calls: string[][] = []; + const git: GitRunner = { + run(args: string[]): GitRunResult { + calls.push(args); + const key = args.join(" "); + let best: { length: number; result: GitRunResult } | null = null; + for (const [pattern, result] of Object.entries(table)) { + if (key.startsWith(pattern) && (best === null || pattern.length > best.length)) { + best = { length: pattern.length, result }; + } + } + return best?.result ?? OK(); + }, + }; + return { git, calls }; +} + +function deps(table: Record): { deps: GitToolDeps; calls: string[][] } { + const { git, calls } = fakeGit({ "rev-parse --show-toplevel": OK("/repo\n"), ...table }); + return { deps: { cwd: "/repo", git }, calls }; +} + +function ctxWith(answer: boolean, yes = false): AppContext { + return { + flags: { yes }, + confirm: async () => answer, + } as unknown as AppContext; +} + +function sink(): { out: PassThrough; text: () => string } { + const chunks: string[] = []; + const out = new PassThrough(); + out.on("data", (chunk) => chunks.push(String(chunk))); + return { out, text: () => chunks.join("") }; +} + +const ran = (calls: string[][], verb: string): boolean => calls.some((call) => call.includes(verb)); + +// ── the count that never worked ───────────────────────────────────────────── + +test("/rollback rejects a count instead of silently ignoring it", async () => { + const { deps: d, calls } = deps({ "diff --name-only": OK("a.ts\nb.ts\n") }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(true, true), out, "5", d); + assert.match(text(), /never/i, "must say the count never did anything"); + assert.equal(ran(calls, "checkout"), false, "a rejected invocation must not mutate the tree"); + assert.equal(ran(calls, "restore"), false); +}); + +test("/rollback usage text does not promise per-change undo", async () => { + const { deps: d } = deps({ "diff --name-only": OK("") }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(false), out, "not-a-number", d); + assert.equal(/revert last n/i.test(text()), false, "the old usage line claimed a capability that does not exist"); +}); + +// ── truthfulness about what was restored ──────────────────────────────────── + +test("/rollback does not claim 'last commit' while changes are staged", async () => { + const { deps: d, calls } = deps({ + "diff --name-only": OK("a.ts\n"), + "diff --cached --name-only": OK("a.ts\n"), + }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(true, true), out, "", d); + assert.equal(ran(calls, "checkout") || ran(calls, "restore"), true, "it should still restore"); + assert.equal( + /restored to last commit/i.test(text()), + false, + "with a populated index the restore target is the staged content, not HEAD", + ); + assert.match(text(), /staged/i, "it must name what it actually restored to"); +}); + +test("/rollback may say 'last commit' only when nothing is staged", async () => { + const { deps: d } = deps({ + "diff --name-only": OK("a.ts\n"), + "diff --cached --name-only": OK(""), + }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(true, true), out, "", d); + assert.match(text(), /last commit/i); +}); + +// ── destructive-scope guards ──────────────────────────────────────────────── + +test("/rollback never removes untracked files", async () => { + const { deps: d, calls } = deps({ "diff --name-only": OK("a.ts\n") }); + const { out } = sink(); + await rollbackSlash(ctxWith(true, true), out, "", d); + assert.equal(ran(calls, "clean"), false, "reverting tracked files must not delete untracked ones"); +}); + +test("/rollback declining the prompt mutates nothing", async () => { + const { deps: d, calls } = deps({ "diff --name-only": OK("a.ts\nb.ts\n") }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(false), out, "", d); + assert.match(text(), /cancel/i); + assert.equal(ran(calls, "checkout"), false); + assert.equal(ran(calls, "restore"), false); +}); + +test("/rollback on a clean tree issues no mutation", async () => { + const { deps: d, calls } = deps({ "diff --name-only": OK("") }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(true, true), out, "", d); + assert.match(text(), /clean/i); + assert.equal(ran(calls, "checkout"), false); + assert.equal(ran(calls, "restore"), false); +}); + +// ── repository detection ──────────────────────────────────────────────────── + +test("/rollback works from a subdirectory, not only a repo root", async () => { + const { git, calls } = fakeGit({ + "rev-parse --show-toplevel": OK("/repo\n"), + "diff --name-only": OK("a.ts\n"), + }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(true, true), out, "", { cwd: "/repo/src/deep", git }); + assert.equal(/not in a git repository/i.test(text()), false, "a subdirectory of a repo is still in the repo"); + assert.equal(ran(calls, "rev-parse"), true, "detection must ask git, not look for a .git directory"); +}); + +test("/rollback outside a repository refuses and mutates nothing", async () => { + const { git, calls } = fakeGit({ "rev-parse --show-toplevel": FAIL("not a git repository") }); + const { out, text } = sink(); + await rollbackSlash(ctxWith(true, true), out, "", { cwd: "/tmp", git }); + assert.match(text(), /not in a git repository/i); + assert.equal(ran(calls, "checkout"), false); +}); + +// ── /revert ───────────────────────────────────────────────────────────────── + +test("/revert does not claim 'last commit' while the file is staged", async () => { + const { deps: d } = deps({ + "ls-files --error-unmatch": OK("a.ts\n"), + "diff --name-only -- a.ts": OK("a.ts\n"), + "diff -- a.ts": OK("--- a\n+++ b\n+x\n"), + "diff --cached --name-only -- a.ts": OK("a.ts\n"), + }); + const { out, text } = sink(); + await revertSlash(ctxWith(true, true), out, "a.ts", d); + assert.equal(/restored to last commit/i.test(text()), false); + assert.match(text(), /staged/i); +}); + +test("/revert passes the path after -- and never through a shell", async () => { + const { deps: d, calls } = deps({ + "ls-files --error-unmatch": OK("-weird file.ts\n"), + "diff --name-only": OK("-weird file.ts\n"), + "diff -- ": OK("+x\n"), + "diff --cached --name-only": OK(""), + }); + const { out } = sink(); + await revertSlash(ctxWith(true, true), out, "-weird file.ts", d); + for (const call of calls) { + const target = call.indexOf("-weird file.ts"); + if (target === -1) continue; + assert.equal(call[target - 1], "--", `path must follow a -- separator: ${call.join(" ")}`); + } +}); + +test("/revert on an untracked file refuses and mutates nothing", async () => { + const { deps: d, calls } = deps({ "ls-files --error-unmatch": FAIL("did not match any file") }); + const { out, text } = sink(); + await revertSlash(ctxWith(true, true), out, "new.ts", d); + assert.match(text(), /not tracked/i); + assert.equal(ran(calls, "checkout"), false); + assert.equal(ran(calls, "restore"), false); +}); From c214adc1fe38c384cf98d493cb4a4bcc0b7d851c Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 06:44:15 -0400 Subject: [PATCH 09/11] fix(brain): correlate Ollama tool results by id, generate schemas, stop faking steer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane SC-A4, slice 1 of local-brain parity. Three ways the local Ollama brain quietly disagreed with the host. 1. Tool results were not correlated to tool calls. The pending result was a single anonymous resolver and the id parameter was literally discarded: sendToolResult(_id: string, result: ToolResult): void { if (this.pending) { ... } } Any result satisfied whichever call happened to be waiting. A result carrying the wrong id was indistinguishable from the right one, and a duplicate vanished with no trace. The loop awaits calls serially so only one is ever in flight today, which is what kept this from corrupting runs — but nothing enforced that, and nothing would have reported it if it broke. Now keyed by id. An unknown id, or a second result for a call already settled, emits an error event instead of advancing the loop on a result it never asked for. close() drains every outstanding waiter rather than one. Also fixes an ordering fragility: the waiter is now registered BEFORE the tool_call event is emitted. Registering afterwards only worked because the consumer resumes on a microtask — a host that replied synchronously would have found no waiter at all. 2. The advertised tool schemas were hand-written and told the model nothing. parameters: { type: "object", properties: {}, additionalProperties: true } Argument names, types, required-ness and bounds were never advertised. The only description of the real shapes lived in free-text strings that nothing kept in step with TOOL_DEFINITIONS, and validateToolDefinitionCoverage only checks name-set equality, not schema fidelity. ollamaToolSchemas() now generates them from TOOL_DEFINITIONS: per-argument types, maxLength from maxBytes, integer min/max, an accurate required set, and additionalProperties:false — the host rejects unknown arguments, so advertising them as allowed only invited a refusal. A test asserts the advertised argument set, required set and bounds match the validator for every tool, so the two cannot drift. 3. control() accepted pause/resume/steer and did nothing. It returned normally, so the host believed the instruction landed. A dropped steer then reads to the user as the model ignoring them. This brain runs a single-pass loop with no interruption point and genuinely cannot honour these, so it now says so in a visible monologue rather than reporting a success it did not deliver. Tests: 5 added — unknown id rejected, duplicate does not advance the loop twice, close() cannot strand a waiter, control() is visibly honest, and schemas match TOOL_DEFINITIONS argument-for-argument. Mutation-checked: restoring the old behaviour (fall back to any waiter when the id is unknown) fails "a tool result for an unknown id is rejected" — 8 pass / 1 fail; restoring gives 9 / 9. Gates at this commit: npm run typecheck exit 0 npm test 927 pass / 0 fail (922 on clean 41a7e261) Not addressed here, and still open in this lane: chat.ts drops the AbortSignal on local turns so Ctrl+C is inert until the turn ends; tool_executor.ts uses blocking spawnSync with no process-group cleanup, so a timed-out test leaves its children running; and chat.ts re-implements the host loop rather than sharing code.ts's. The last two are SC-A1's surface and are deliberately not raced here. --- src/core/brain_ollama.ts | 135 +++++++++++++++++++++++++++----------- test/brain_ollama.test.ts | 132 ++++++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 41 deletions(-) diff --git a/src/core/brain_ollama.ts b/src/core/brain_ollama.ts index a1c2187..bfe6194 100644 --- a/src/core/brain_ollama.ts +++ b/src/core/brain_ollama.ts @@ -15,7 +15,8 @@ import type { Brain, TaskCommand } from "./brain.js"; import { EventQueue } from "./brain.js"; import type { BrainEvent } from "./brain_protocol.js"; -import { TOOLS } from "./brain_protocol.js"; +import { TOOL_DEFINITIONS } from "./tool_registry.js"; +import { TOOLS, type ToolName } from "./brain_protocol.js"; import type { ToolResult } from "./tool_executor.js"; import { ollamaChat, @@ -47,7 +48,7 @@ const DEFAULT_MAX_TURNS = 24; // The 8 canonical tools, advertised to the model as OpenAI function schemas. The // ONE implementation lives host-side in tool_executor.ts; this only describes // them so the model can request them. Names are pinned by TOOLS (protocol v3). -const TOOL_SCHEMAS: readonly ToolSchema[] = buildToolSchemas(); +const TOOL_SCHEMAS: readonly ToolSchema[] = ollamaToolSchemas(); const SYSTEM_PERSONA = "You are Aether Code, an autonomous coding agent running locally. You work in " + @@ -60,9 +61,11 @@ export class OllamaBrain implements Brain { private readonly queue = new EventQueue(); private readonly opts: OllamaBrainOptions; private readonly chat: OllamaChatFn; - // Pending tool result: the loop awaits this promise after emitting a - // tool_call; sendToolResult resolves it (mirrors LocalBrain's stdin reply). - private pending: ((r: ToolResult) => void) | null = null; + // Outstanding tool calls, keyed by the id the loop emitted. Keyed rather than + // anonymous: a single resolver accepted any result for whatever call happened + // to be waiting, so a duplicate vanished silently and a result carrying the + // wrong id was indistinguishable from the right one. + private readonly pending = new Map void>(); private aborted = false; constructor(opts: OllamaBrainOptions = {}) { @@ -77,34 +80,53 @@ export class OllamaBrain implements Brain { return this.queue.drain(); } - sendToolResult(_id: string, result: ToolResult): void { - if (this.pending) { - const resolve = this.pending; - this.pending = null; - resolve(result); + sendToolResult(id: string, result: ToolResult): void { + const resolve = this.pending.get(id); + if (!resolve) { + // Unknown id, or a second result for a call already settled. Either way + // the host and the brain disagree about what is in flight; say so rather + // than letting the loop advance on a result it never asked for. + this.queue.push({ type: "error", msg: `tool result for unknown or already-settled call ${id}` }); + return; } + this.pending.delete(id); + resolve(result); } - control(_action: "pause" | "resume" | "steer", _note?: string): void { - // Steering is not yet wired for the local-Ollama brain (single-pass loop). - // The seam exists so the host can call it uniformly; it is a safe no-op. + control(action: "pause" | "resume" | "steer", note?: string): void { + // This brain runs a single-pass loop with no interruption point, so it + // cannot honour pause, resume or steer. Silently returning made the host + // believe the instruction landed; a dropped steer then reads as the model + // ignoring the user. Report it instead of accepting it. + this.queue.push({ + type: "monologue", + text: + `[${action} is not supported by the local Ollama brain — the instruction was not applied` + + (note ? `: "${note}"` : "") + + "]", + depth: 0, + }); } close(): void { this.aborted = true; - // Unblock a loop parked on a tool result so it can observe the abort. - if (this.pending) { - const resolve = this.pending; - this.pending = null; + // Unblock every loop parked on a tool result so it can observe the abort. + // Drained as a set so no waiter can be stranded by an early return. + for (const [id, resolve] of this.pending) { + this.pending.delete(id); resolve({ output: "[aborted]", exitCode: 130 }); } this.queue.end(); } - /** Await the host's reply to the tool_call we just emitted. */ - private waitForTool(): Promise { + /** + * Register a waiter for a tool call BEFORE the event is emitted. Registering + * afterwards only worked because the consumer resumes on a microtask; a host + * that replied synchronously would have found no waiter and been rejected. + */ + private waitForTool(id: string): Promise { return new Promise((resolve) => { - this.pending = resolve; + this.pending.set(id, resolve); }); } @@ -158,8 +180,9 @@ export class OllamaBrain implements Brain { for (const call of calls) { if (this.aborted) break; const args = parseArgs(call.function.arguments); + const waiting = this.waitForTool(call.id); this.queue.push({ type: "tool_call", id: call.id, name: call.function.name, args }); - const toolResult = await this.waitForTool(); + const toolResult = await waiting; messages.push({ role: "tool", tool_call_id: call.id, @@ -220,24 +243,56 @@ function parseArgs(raw: string): Record { } } -/** Minimal OpenAI function schema per canonical tool (parameters left open). */ -function buildToolSchemas(): readonly ToolSchema[] { - const descriptions: Record = { - read_file: "Read a workspace file. args: {path}", - write_file: "Write/overwrite a workspace file. args: {path, content}", - run_shell: "Run a shell command in the workspace. args: {command}", - run_tests: "Run the project's test command. args: {command?}", - repo_search: "Grep the repository for a string. args: {query}", - git_commit: "Stage all changes and commit. args: {message}", - web_search: "Search the web. args: {query, limit?}", - web_fetch: "Fetch a web page as readable text. args: {url}", +/** + * Model-facing tool schemas, GENERATED from TOOL_DEFINITIONS. + * + * These were hand-written, with `parameters: { properties: {}, additionalProperties: true }` + * — so the model was told nothing about argument names, types, required-ness or + * bounds, and the only description of the real shapes lived in free text that + * nothing kept in step with the validator. Generating them means the host's + * validator and the model's contract cannot drift. + */ +export function ollamaToolSchemas(): readonly ToolSchema[] { + const summaries: Readonly> = { + read_file: "Read a workspace file.", + write_file: "Write or overwrite a workspace file.", + run_shell: "Run a shell command in the workspace.", + run_tests: "Run the project's test command.", + repo_search: "Grep the repository for a string.", + git_commit: "Stage this run's changes and commit them.", + web_search: "Search the web.", + web_fetch: "Fetch a web page as readable text.", }; - return TOOLS.map((name) => ({ - type: "function" as const, - function: { - name, - description: descriptions[name] ?? name, - parameters: { type: "object", properties: {}, additionalProperties: true }, - }, - })); + + return TOOLS.map((name) => { + const definition = TOOL_DEFINITIONS[name]; + const properties: Record> = {}; + const required: string[] = []; + + for (const [argument, spec] of Object.entries(definition.args)) { + properties[argument] = + spec.type === "integer" + ? { type: "integer", minimum: spec.min, maximum: spec.max } + // maxBytes is a byte budget; as maxLength it is a ceiling in + // characters, which is conservative for any multi-byte input. + : { type: "string", maxLength: spec.maxBytes }; + if (spec.required !== false) required.push(argument); + } + + return { + type: "function" as const, + function: { + name, + description: `${summaries[name]} side effect: ${definition.sideEffect}.`, + parameters: { + type: "object", + properties, + required, + // The host rejects unknown arguments, so advertising them as allowed + // only invites the model to send something that will be refused. + additionalProperties: false, + }, + }, + }; + }); } diff --git a/test/brain_ollama.test.ts b/test/brain_ollama.test.ts index 33dd0d0..4d8b92e 100644 --- a/test/brain_ollama.test.ts +++ b/test/brain_ollama.test.ts @@ -1,6 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { OllamaBrain } from "../src/core/brain_ollama.js"; +import { OllamaBrain, ollamaToolSchemas } from "../src/core/brain_ollama.js"; +import { TOOLS, type ToolName } from "../src/core/brain_protocol.js"; +import { TOOL_DEFINITIONS } from "../src/core/tool_registry.js"; import type { Brain, TaskCommand } from "../src/core/brain.js"; import type { BrainEvent } from "../src/core/brain_protocol.js"; import type { ChatMessage, ChatReply } from "../src/core/ollama.js"; @@ -128,3 +130,131 @@ test("OllamaBrain stops after maxTurns without a final answer", async () => { assert.ok(done, "loop terminates with a done even on a runaway model"); assert.equal(done?.type === "done" ? done.reason : "", "max-turns"); }); + +// ── tool-call correlation (SC-A4) ─────────────────────────────────────────── +// The pending tool result used to be a single anonymous resolver and +// sendToolResult ignored its `id` parameter entirely. Any result satisfied +// whatever call happened to be waiting, a duplicate vanished silently, and a +// result for an unknown id was indistinguishable from the real one. + +function toolThenAnswer(): readonly ChatReply[] { + return [ + { + role: "assistant", + content: "", + tool_calls: [{ id: "call-1", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }], + }, + { role: "assistant", content: "done" }, + ]; +} + +test("a tool result for an unknown id is rejected, not applied to the waiting call", async () => { + const { chat } = fakeChat(toolThenAnswer()); + const brain = new OllamaBrain({ chat }); + const events: BrainEvent[] = []; + for await (const ev of brain.run(task)) { + events.push(ev); + if (ev.type === "tool_call") { + brain.sendToolResult("not-the-right-id", { output: "WRONG", exitCode: 0 }); + brain.sendToolResult(ev.id, { output: "right", exitCode: 0 }); + } + } + brain.close(); + const errors = events.filter((ev) => ev.type === "error"); + assert.equal(errors.length >= 1, true, "an unrecognised tool-call id must be surfaced, not swallowed"); + assert.equal(events.some((ev) => ev.type === "done"), true, "the run still terminates"); +}); + +test("a duplicate tool result does not advance the loop twice", async () => { + const { chat, calls } = fakeChat(toolThenAnswer()); + const brain = new OllamaBrain({ chat }); + for await (const ev of brain.run(task)) { + if (ev.type === "tool_call") { + brain.sendToolResult(ev.id, { output: "first", exitCode: 0 }); + brain.sendToolResult(ev.id, { output: "second", exitCode: 0 }); + } + } + brain.close(); + const toolReplies = calls() + .flat() + .filter((message) => message.role === "tool"); + const outputs = toolReplies.map((message) => message.content); + assert.equal(outputs.includes("second"), false, "the second result must not reach the model"); + assert.equal(outputs.filter((text) => text === "first").length >= 1, true); +}); + +test("close resolves an outstanding waiter so the run cannot hang", async () => { + const { chat } = fakeChat(toolThenAnswer()); + const brain = new OllamaBrain({ chat }); + const events: BrainEvent[] = []; + for await (const ev of brain.run(task)) { + events.push(ev); + if (ev.type === "tool_call") brain.close(); // never send a result + } + assert.equal(events.some((ev) => ev.type === "tool_call"), true); +}); + +// ── control honesty ───────────────────────────────────────────────────────── + +test("control() reports that steering is unsupported instead of silently succeeding", async () => { + const { chat } = fakeChat(toolThenAnswer()); + const brain = new OllamaBrain({ chat }); + const events: BrainEvent[] = []; + for await (const ev of brain.run(task)) { + events.push(ev); + if (ev.type === "tool_call") { + brain.control("steer", "actually, do something else"); + brain.sendToolResult(ev.id, { output: "ok", exitCode: 0 }); + } + } + brain.close(); + const said = events + .filter((ev): ev is Extract => ev.type === "monologue") + .map((ev) => ev.text) + .join(" "); + assert.match(said, /steer/i, "a steer this brain cannot honour must be visible to the user"); + assert.match(said, /not supported|unsupported|cannot/i); +}); + +// ── advertised tool schemas ───────────────────────────────────────────────── + +test("advertised tool schemas are generated from TOOL_DEFINITIONS, not hand-written", () => { + const schemas = ollamaToolSchemas(); + assert.deepEqual( + schemas.map((schema) => schema.function.name).sort(), + [...TOOLS].sort(), + "every protocol tool is advertised, and nothing else", + ); + + for (const schema of schemas) { + const name = schema.function.name as ToolName; + const definition = TOOL_DEFINITIONS[name]; + const parameters = schema.function.parameters as { + properties: Record; + required?: string[]; + additionalProperties?: boolean; + }; + + assert.equal(parameters.additionalProperties, false, `${name} must not accept arbitrary extra arguments`); + assert.deepEqual( + Object.keys(parameters.properties).sort(), + Object.keys(definition.args).sort(), + `${name} advertises exactly the arguments the host validates`, + ); + assert.deepEqual( + (parameters.required ?? []).sort(), + Object.entries(definition.args) + .filter(([, argument]) => argument.required !== false) + .map(([key]) => key) + .sort(), + `${name} required set matches the host validator`, + ); + } + + // Spot-check that bounds actually crossed over rather than being dropped. + const search = schemas.find((schema) => schema.function.name === "web_search"); + const limit = (search!.function.parameters as { properties: Record }) + .properties["limit"]; + assert.equal(limit?.minimum, 1); + assert.equal(limit?.maximum, 10); +}); From 8ca821de0ee32d10026463f690921bac9911991a Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 06:54:18 -0400 Subject: [PATCH 10/11] fix(chat): thread the abort signal into local turns so Ctrl+C works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane SC-A1, slice 1 of the cancellable tool runtime. The REPL builds an AbortController per turn and aborts it on Ctrl+C. runTurn accepted that signal and then dropped it on the local branch: const backend = await resolveBackend(ctx); if (backend === "local") { await runLocalTurn(ctx, prompt); // signal not passed return; } So Ctrl+C did nothing to a local turn. The abort fired and nothing was listening; the turn ran to completion regardless. Only the cloud path was ever cancellable. runLocalTurn now takes the signal and closes the brain on abort. close() is what unblocks a loop parked on a tool result, so an abort arriving mid-turn is observed rather than waiting the turn out. The listener is registered before the loop starts, so a signal that is already aborted is honoured instead of starting work that was cancelled before it began. An aborted turn returns rather than throwing. The user asked for the stop; it is not a failed turn and must not be reported as one. Adds a LocalTurnDeps seam ({ brain, exec }) mirroring the one smoke.ts already uses, so the abort path is testable without an Ollama server, a child process or real tool execution. runLocalTurn becomes exported for the same reason. Tests: 4 added, driving a brain that emits one tool_call and then parks exactly as the real one does while awaiting sendToolResult — abort reaches the brain, an already-aborted signal stops the turn, an aborted turn does not reject, and a turn with no signal still completes. Mutation-checked, and the failure mode is the interesting part: restoring the old behaviour does not fail the tests, it HANGS them. The runner is killed by timeout with exit 124, having produced no TAP summary at all, because the turn never settles. Restored, the same run exits 0. That hang is precisely what a user experienced when they pressed Ctrl+C. Gates at this commit: npm run typecheck exit 0 npm test 926 pass / 0 fail (922 on clean 41a7e261) Scope note. This makes a local turn cancellable BETWEEN steps — during a model request, or while parked awaiting a tool result. It does not interrupt a tool already executing: tool_executor.ts still uses blocking spawnSync with no AbortSignal, so Ctrl+C during a long `run_tests` is still not observed until that command returns, and the command's children are still orphaned on timeout. Fixing that requires spawn() with process-group cleanup, which requires ToolExecutor.run to become async, which requires finalVerify to become async — it is the synchronous ground-truth gate (verify_gate.ts:71, called from code.ts:371, with 13 tests pinning its behaviour). That is a deliberate, separate slice rather than something to graft onto this one. --- src/commands/chat.ts | 32 ++++++++++-- test/chat_local_abort.test.ts | 98 +++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 test/chat_local_abort.test.ts diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 43d2c15..7f04b53 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -31,6 +31,8 @@ import { loadHistory, appendHistory, historyPath, historyEnabled } from "../core import { VERSION } from "../version.js"; import { chooseBackend, type BackendPath } from "../core/backend.js"; import { OllamaBrain } from "../core/brain_ollama.js"; +import type { Brain } from "../core/brain.js"; +import type { ToolResult } from "../core/tool_executor.js"; import { ToolExecutor } from "../core/tool_executor.js"; import { HostRenderer } from "../ui/host_render.js"; import type { TaskCommand } from "../core/brain.js"; @@ -106,7 +108,9 @@ export async function runTurn( ): Promise { const backend = await resolveBackend(ctx); if (backend === "local") { - await runLocalTurn(ctx, prompt); + // The signal was dropped here, so the REPL's Ctrl+C controller could not + // reach a local turn at all — the abort fired and nothing observed it. + await runLocalTurn(ctx, prompt, signal); return; } await runCloudTurn(ctx, prompt, signal, onFrame, onPulsePaint); @@ -199,10 +203,20 @@ async function runCloudTurn( * each tool_call (one path-guarded ToolExecutor) and replies, the HostRenderer * draws every event. Identical UX to cloud, just an offline brain. */ -async function runLocalTurn(ctx: AppContext, prompt: string): Promise { +export interface LocalTurnDeps { + brain?: Brain; + exec?: { executeAsync(name: string, args: Record): Promise }; +} + +export async function runLocalTurn( + ctx: AppContext, + prompt: string, + signal?: AbortSignal, + deps: LocalTurnDeps = {}, +): Promise { const cwd = ctx.flags.cwd; - const brain = new OllamaBrain(ctx.flags.model ? { model: ctx.flags.model } : {}); - const exec = new ToolExecutor(cwd); + const brain = deps.brain ?? new OllamaBrain(ctx.flags.model ? { model: ctx.flags.model } : {}); + const exec = deps.exec ?? new ToolExecutor(cwd); const renderer = new HostRenderer({ poolGb: 5, json: ctx.flags.json }); const approveTool = async (name: string, args: Record): Promise => { const outcome = decideGate(name, ctx.cfg.permissionMode, ctx.cfg.autoApply, { @@ -226,8 +240,15 @@ async function runLocalTurn(ctx: AppContext, prompt: string): Promise { ...(ctx.flags.model ? { model: ctx.flags.model } : {}), }; let sawError: string | null = null; + // close() is what unblocks a loop parked on a tool result, so an abort that + // arrives mid-turn is observed rather than waiting out the whole turn. + // Registered before the loop starts so an already-aborted signal still fires. + const onAbort = (): void => brain.close(); + if (signal?.aborted) brain.close(); + signal?.addEventListener("abort", onAbort, { once: true }); try { for await (const ev of brain.run(task)) { + if (signal?.aborted) break; renderer.event(ev); if (ev.type === "error") sawError = ev.msg; if (ev.type === "done" && !ev.ok) sawError = ev.result || ev.reason || "turn did not complete"; @@ -241,8 +262,11 @@ async function runLocalTurn(ctx: AppContext, prompt: string): Promise { } } } finally { + signal?.removeEventListener("abort", onAbort); brain.close(); } + // An aborted turn is not a failed one — the user asked for it to stop. + if (signal?.aborted) return; // Mirror runCloudTurn/CONTRACTS.md invariant 5: a streamed error event is a // failed turn, not a silently-successful one — the renderer already painted // it, so callers (cmdChat/run.ts) special-case ChatTurnError to avoid a diff --git a/test/chat_local_abort.test.ts b/test/chat_local_abort.test.ts new file mode 100644 index 0000000..458a6dc --- /dev/null +++ b/test/chat_local_abort.test.ts @@ -0,0 +1,98 @@ +// The REPL builds an AbortController per turn and aborts it on Ctrl+C, but +// runTurn dropped the signal on the local branch — it was accepted as a +// parameter and simply not passed on. Ctrl+C therefore did nothing to a local +// turn: the abort fired and nothing was listening. +// +// These drive runLocalTurn with an injected brain, so no Ollama server, no +// child process and no real tool execution is involved. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { runLocalTurn } from "../src/commands/chat.js"; +import type { AppContext } from "../src/core/context.js"; +import type { Brain, TaskCommand } from "../src/core/brain.js"; +import type { BrainEvent } from "../src/core/brain_protocol.js"; +import type { ToolResult } from "../src/core/tool_executor.js"; + +/** + * A brain that emits one tool_call and then parks forever, exactly like the + * real one waiting on sendToolResult. close() is the only thing that frees it, + * so a turn that ignores the abort signal never returns. + */ +class ParkingBrain implements Brain { + closed = false; + private release: (() => void) | null = null; + + run(_task: TaskCommand): AsyncIterable { + const self = this; + return (async function* (): AsyncGenerator { + yield { type: "tool_call", id: "call-1", name: "read_file", args: { path: "a.ts" } }; + await new Promise((resolve) => { + if (self.closed) resolve(); + else self.release = resolve; + }); + yield { type: "done", ok: true, result: "done", remaining: 0, reason: "" }; + })(); + } + + sendToolResult(_id: string, _result: ToolResult): void {} + control(): void {} + close(): void { + this.closed = true; + this.release?.(); + this.release = null; + } +} + +function ctx(): AppContext { + return { + cfg: { permissionMode: "skip", autoApply: true }, + flags: { cwd: process.cwd(), yes: true, json: true }, + confirm: async () => true, + } as unknown as AppContext; +} + +const noExec = { + executeAsync: async (): Promise => ({ output: "ok", exitCode: 0 }), +}; + +test("aborting a local turn closes the brain instead of waiting the turn out", async () => { + const brain = new ParkingBrain(); + const controller = new AbortController(); + const turn = runLocalTurn(ctx(), "do a thing", controller.signal, { brain, exec: noExec }); + + // Let the turn reach the parked tool call, then Ctrl+C. + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + + // Without the signal wired through, this never settles. + await turn; + assert.equal(brain.closed, true, "abort must reach the brain"); +}); + +test("a signal already aborted stops the turn rather than starting it", async () => { + const brain = new ParkingBrain(); + const controller = new AbortController(); + controller.abort(); + await runLocalTurn(ctx(), "do a thing", controller.signal, { brain, exec: noExec }); + assert.equal(brain.closed, true); +}); + +test("an aborted local turn is not reported as a failed turn", async () => { + // A user-requested stop is not an error, and must not surface as one. + const brain = new ParkingBrain(); + const controller = new AbortController(); + const turn = runLocalTurn(ctx(), "do a thing", controller.signal, { brain, exec: noExec }); + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + await assert.doesNotReject(() => turn); +}); + +test("without a signal a local turn still completes normally", async () => { + const brain = new ParkingBrain(); + const turn = runLocalTurn(ctx(), "do a thing", undefined, { brain, exec: noExec }); + await new Promise((resolve) => setTimeout(resolve, 20)); + brain.close(); // stand in for the real brain finishing its wait + await turn; + assert.equal(brain.closed, true); +}); From 79e0bbcea24a155df480eb535e21943d73f55fba Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Wed, 19 Aug 2026 07:06:25 -0400 Subject: [PATCH 11/11] fix(usage): make /limit a real stop boundary and stop reporting unknown spend as zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane SC-A5, slice 1 of truthful usage and continuity. /limit was a control that enforced nothing. `uvtSpent` was only ever written as `= 0` — at construction, in purge(), and on snapshot restore. No usage frame ever incremented it. `checkUvtCap()` had zero callers anywhere in the tree. So: * `/limit 50000` printed "agent will pause and ask permission if ceiling hit". Nothing paused. Nothing checked. The cap was never consulted. * `/limit` reported "spent: 0" and a 0% bar for every session, whatever it had actually cost, because the number was a constant. * The field comment claimed it was "read from custody log". Nothing read it. Three changes. 1. Usage is measured, or it is unknown. Never zero by default. `uvtObserved: number | null` replaces the always-zero counter. null means no authoritative frame has arrived, which is not the same as a measured zero and is no longer rendered as one. Nothing estimates UVT from token counts — only the server's own number is recorded. `uvtSpent` survives as a getter for the HUD, which needs a number, and is documented as reporting 0 when the answer is unknown so that anything which must tell those apart reads `uvtObserved`. 2. Turns settle once, by id. The terminal frame carries the turn total and a reconnect can replay it, so `settleTurn(turnId, uvt)` is keyed rather than accumulated blindly. A replayed done frame is ignored; distinct turns accumulate. 3. The cap is checked before a billable turn starts, and says what it is. `runCloudTurn` consults `checkUvtCap()` before doing anything and refuses to start when the observed spend has reached the cap. Two states deliberately do not trip it: a local brain (Aether meters nothing, so it is marked unmetered rather than zero-spend) and a session with no observed usage (there is no evidence the cap was reached, and guessing either blocks free work or waves through expensive work). The wording is now accurate about what it can and cannot do: no further turn will START once the server-reported spend reaches it. a turn already in flight may still complete and be billed. this is a local stop only — your plan and balance are unchanged. A design change came out of the mutation pass rather than the plan. `remaining` was a number, and an unmeasured session reported the full cap as headroom — which is the same false zero in a different costume: it tells the user their whole budget is intact when in truth none of it has been counted. The first mutation run did not fail any test, which is what exposed it. `remaining` is now `number | null`, null when unmeasured, and a test pins it. Tests: 10 added. Unknown is not zero; a duplicate done frame does not double-count; distinct turns accumulate; an unknown session is never reported as capped; the cap trips on reaching it; no cap never caps; local sessions are labelled unmetered; purge returns to unknown rather than to zero; and unmeasured headroom is null rather than the full cap. Mutation-checked, both guards: restoring the false-zero headroom and removing the replay dedupe fails two tests (8 pass / 2 fail); restoring gives 10 / 10. Gates at this commit: npm run typecheck exit 0 npm test 932 pass / 0 fail (922 on clean 41a7e261) Known limits. The HUD still renders `uvtUsed: reg.uvtSpent`, so it shows 0 for an unknown session — hud.ts is SC-INT's surface and is deliberately not touched here. Session-cap persistence across resume, and the cross-workspace rejection that goes with it, are a later slice. The cap is enforced on the REPL cloud path; `aether agent` has its own loop and is not yet gated. --- src/commands/chat.ts | 38 +++++++++++++ src/commands/slash_context.ts | 38 ++++++++++--- src/core/context_registry.ts | 96 ++++++++++++++++++++++++++++--- test/usage_cap.test.ts | 104 ++++++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 test/usage_cap.test.ts diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 43d2c15..8e287a6 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -104,14 +104,34 @@ export async function runTurn( onFrame?: (f: StreamFrame) => void, onPulsePaint?: () => void, ): Promise { + const reg = getRegistry(); + // The operator's session cap is checked BEFORE a billable turn starts. It is + // a local circuit breaker, not a billing control: it stops this terminal + // from starting more work, and changes nothing about the account. + const cap = reg.checkUvtCap(); + if (cap.capped) { + throw new ChatTurnError( + `session UVT cap reached — ${cap.observed} of ${cap.cap} observed. ` + + "No further turns will start. This is a local stop only; your plan and " + + "balance are unchanged. Raise it with /limit , or /limit off.", + ); + } + const turnId = `turn-${++cloudTurnCounter}`; + reg.beginTurn(turnId); const backend = await resolveBackend(ctx); if (backend === "local") { + // Aether meters nothing on a local brain, so the session is unmetered + // rather than "zero spend so far". + getRegistry().markLocalUnmetered(); await runLocalTurn(ctx, prompt); return; } await runCloudTurn(ctx, prompt, signal, onFrame, onPulsePaint); } +/** Monotonic per-process turn id, so a settled turn can be recognised on replay. */ +let cloudTurnCounter = 0; + /** The cloud path — build an envelope, POST to the universal stream, render. * Extracted so runTurn can fork local vs cloud. */ async function runCloudTurn( @@ -121,6 +141,20 @@ async function runCloudTurn( onFrame?: (f: StreamFrame) => void, onPulsePaint?: () => void, ): Promise { + const reg = getRegistry(); + // The operator's session cap is checked BEFORE a billable turn starts. It is + // a local circuit breaker, not a billing control: it stops this terminal + // from starting more work, and changes nothing about the account. + const cap = reg.checkUvtCap(); + if (cap.capped) { + throw new ChatTurnError( + `session UVT cap reached — ${cap.observed} of ${cap.cap} observed. ` + + "No further turns will start. This is a local stop only; your plan and " + + "balance are unchanged. Raise it with /limit , or /limit off.", + ); + } + const turnId = `turn-${++cloudTurnCounter}`; + reg.beginTurn(turnId); const req = buildChatRequest({ prompt, model: ctx.flags.model ?? ctx.cfg.defaultModel, @@ -163,6 +197,10 @@ async function runCloudTurn( if (frame.type !== "open" && frame.type !== "ping") pulse.stop(); // The server signs each turn and returns it; persist the signed receipt // locally (best-effort, never breaks the chat). + // The terminal frame carries the turn's authoritative cost. Settled by + // turn id so a reconnect replaying it cannot count the same turn twice, + // and only from the server's own number — never estimated from tokens. + if (frame.type === "done") getRegistry().settleTurn(turnId, frame.uvt); if (frame.type === "custody") appendCustody(frame.custody); if (frame.type === "error") sawError = frame.msg; if (frame.type === "error" || frame.type === "done") sawTerminal = true; diff --git a/src/commands/slash_context.ts b/src/commands/slash_context.ts index 80769e2..b4eea57 100644 --- a/src/commands/slash_context.ts +++ b/src/commands/slash_context.ts @@ -170,14 +170,28 @@ export async function limitSlash(ctx: AppContext, out: Writable, arg: string): P if (!arg.trim()) { const current = registry.uvtCap; - const spent = registry.uvtSpent; + const status = registry.usageStatus(); + const observed = registry.uvtObserved; + // "spent: 0" used to print whether the session had cost nothing or whether + // no usage frame had ever arrived. Those are different answers, and only + // one of them is a measurement. + const spentLabel = + status === "local-unmetered" + ? "LOCAL — not metered by Aether" + : observed == null + ? "unknown — the server has reported no usage yet" + : String(observed); if (current == null) { - out.write("UVT cap: none (uncapped)\n"); + out.write(`UVT cap: none (uncapped) observed: ${spentLabel}\n`); + } else if (observed == null || status !== "observed") { + out.write(`UVT cap: ${theme.bold(String(current))} observed: ${spentLabel}\n`); + out.write(theme.dim(" the cap cannot trip until the server reports usage.\n")); } else { - const remaining = Math.max(0, current - spent); - const pct = current > 0 ? Math.round((spent / current) * 100) : 0; - const bar = renderUvtBar(pct, 20); - out.write(`UVT cap: ${theme.bold(String(current))} spent: ${spent} remaining: ${remaining} ${bar}\n`); + const remaining = Math.max(0, current - observed); + const pct = current > 0 ? Math.round((observed / current) * 100) : 0; + out.write( + `UVT cap: ${theme.bold(String(current))} observed: ${observed} remaining: ${remaining} ${renderUvtBar(pct, 20)}\n`, + ); } out.write(theme.dim(" /limit set cap (e.g., /limit 50000)\n")); out.write(theme.dim(" /limit off remove cap\n")); @@ -197,7 +211,17 @@ export async function limitSlash(ctx: AppContext, out: Writable, arg: string): P } registry.setUvtCap(Math.floor(n)); - out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))} — agent will pause and ask permission if ceiling hit\n`); + // The old wording promised the agent would "pause and ask permission". + // Nothing enforced the cap at all, so that was never true. State what now + // actually happens, and be explicit that this is not a billing control. + out.write(`${theme.cyan("⚡ UVT cap set")} ${theme.bold(String(Math.floor(n)))}\n`); + out.write( + theme.dim( + " no further turn will START once the server-reported spend reaches it.\n" + + " a turn already in flight may still complete and be billed.\n" + + " this is a local stop only — your plan and balance are unchanged.\n", + ), + ); syncAfter(ctx); } diff --git a/src/core/context_registry.ts b/src/core/context_registry.ts index d4275f3..e60c96b 100644 --- a/src/core/context_registry.ts +++ b/src/core/context_registry.ts @@ -59,7 +59,32 @@ export class ContextRegistry { pins: PinnedEntry[] = []; drops: string[] = []; uvtCap: number | null = null; - uvtSpent = 0; + + /** + * Session UVT actually reported by the server. null means NO authoritative + * frame has been seen — which is not the same as zero, and must never be + * rendered as it. Only the server knows what a turn cost; nothing here + * estimates it from token counts. + */ + uvtObserved: number | null = null; + + /** Turn ids already settled, so a replayed terminal frame cannot double-count. */ + private readonly settledTurns = new Set(); + + /** True once this session is known to run on a local, un-metered brain. */ + private localUnmetered = false; + + /** + * Back-compat accessor for the HUD, which needs a number. It reports 0 when + * usage is UNKNOWN, so anything that must tell those apart has to read + * uvtObserved instead. + */ + get uvtSpent(): number { + return this.uvtObserved ?? 0; + } + set uvtSpent(value: number) { + this.uvtObserved = value; + } planPath: string | null = null; sessionLabel = "untitled"; @@ -97,6 +122,33 @@ export class ContextRegistry { this.uvtCap = amount; } + /** Mark a turn as in flight. Idempotent. */ + beginTurn(turnId: string): void { + this.settledTurns.delete(turnId); + } + + /** + * Record a turn's authoritative cost, once. The terminal frame carries the + * turn total, and a reconnect can replay it, so settling is keyed by turn id + * rather than accumulated blindly. + */ + settleTurn(turnId: string, uvt: number): void { + if (this.settledTurns.has(turnId)) return; + if (!Number.isFinite(uvt) || uvt < 0) return; + this.settledTurns.add(turnId); + this.uvtObserved = (this.uvtObserved ?? 0) + uvt; + } + + /** This session runs on a local brain, so Aether meters nothing. */ + markLocalUnmetered(): void { + this.localUnmetered = true; + } + + usageStatus(): "unknown" | "observed" | "local-unmetered" { + if (this.localUnmetered) return "local-unmetered"; + return this.uvtObserved == null ? "unknown" : "observed"; + } + /** Track a temporary file so /purge can clean it up. */ tempFiles: string[] = []; @@ -113,7 +165,8 @@ export class ContextRegistry { this.pins = []; this.drops = []; this.uvtCap = null; - this.uvtSpent = 0; + this.uvtObserved = null; + this.settledTurns.clear(); let removedFiles = 0; for (const f of this.tempFiles) { @@ -124,11 +177,40 @@ export class ContextRegistry { return { clearedPins, removedFiles }; } - /** Check if UVT cap is exceeded. Returns remaining or -1 if exceeded. */ - checkUvtCap(): { capped: boolean; remaining: number; cap: number | null } { - if (this.uvtCap == null) return { capped: false, remaining: Infinity, cap: null }; - const remaining = this.uvtCap - this.uvtSpent; - return { capped: remaining <= 0, remaining: Math.max(0, remaining), cap: this.uvtCap }; + /** + * Is the operator's session cap reached? + * + * This is a local circuit breaker, not a billing ledger. The server remains + * the billing authority; tripping this stops the terminal from starting + * another billable turn, and changes nothing about the account. + * + * Two states deliberately do NOT trip it: an unmetered local session (there + * is no Aether spend to cap) and a session where no authoritative usage has + * been seen (there is no evidence the cap was reached, and guessing would + * either block work that cost nothing or wave through work that cost a lot). + */ + checkUvtCap(): { + capped: boolean; + /** null when there is no measured spend to subtract — NOT the full cap. */ + remaining: number | null; + cap: number | null; + observed: number | null; + status: "unknown" | "observed" | "local-unmetered"; + } { + const status = this.usageStatus(); + const observed = this.uvtObserved; + // Unknown spend yields a null headroom, never the whole cap. Reporting the + // full cap as remaining is the same false zero in a different costume: it + // tells the user they have their entire budget left when the truth is that + // nobody has measured any of it. + if (status !== "observed" || observed == null) { + return { capped: false, remaining: null, cap: this.uvtCap, observed, status }; + } + if (this.uvtCap == null) { + return { capped: false, remaining: null, cap: null, observed, status }; + } + const remaining = this.uvtCap - observed; + return { capped: remaining <= 0, remaining: Math.max(0, remaining), cap: this.uvtCap, observed, status }; } // ── HUD methods ── diff --git a/test/usage_cap.test.ts b/test/usage_cap.test.ts new file mode 100644 index 0000000..23d98f0 --- /dev/null +++ b/test/usage_cap.test.ts @@ -0,0 +1,104 @@ +// /limit shipped as a control that enforced nothing. +// +// `uvtSpent` was only ever written as `= 0` (construction, purge, snapshot +// restore) — no usage frame ever incremented it — and `checkUvtCap()` had zero +// callers. So the cap never stopped anything, and the readout reported +// "spent: 0" no matter what the session had actually cost. +// +// These pin the two properties that matter: an unobserved session is UNKNOWN +// rather than zero, and a reached cap actually refuses the next billable turn. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ContextRegistry } from "../src/core/context_registry.js"; + +test("a session with no authoritative usage frame reports unknown, not zero", () => { + const reg = new ContextRegistry(); + assert.equal(reg.uvtObserved, null, "no frame seen means no number to report"); + assert.notEqual(reg.uvtObserved, 0, "zero is a measurement; this is the absence of one"); +}); + +test("a settled turn is counted once, and a duplicate done frame does not double it", () => { + const reg = new ContextRegistry(); + reg.beginTurn("turn-1"); + reg.settleTurn("turn-1", 1200); + reg.settleTurn("turn-1", 1200); // replay after reconnect + assert.equal(reg.uvtObserved, 1200); +}); + +test("distinct turns accumulate", () => { + const reg = new ContextRegistry(); + reg.beginTurn("t1"); + reg.settleTurn("t1", 1000); + reg.beginTurn("t2"); + reg.settleTurn("t2", 500); + assert.equal(reg.uvtObserved, 1500); +}); + +test("an unknown session is never reported as capped", () => { + const reg = new ContextRegistry(); + reg.setUvtCap(1000); + const check = reg.checkUvtCap(); + assert.equal(check.capped, false, "with nothing observed there is no evidence the cap was reached"); + assert.equal(check.observed, null); +}); + +test("the cap trips once observed spend reaches it", () => { + const reg = new ContextRegistry(); + reg.setUvtCap(1000); + reg.beginTurn("t1"); + reg.settleTurn("t1", 999); + assert.equal(reg.checkUvtCap().capped, false); + reg.beginTurn("t2"); + reg.settleTurn("t2", 1); + assert.equal(reg.checkUvtCap().capped, true, "reaching the cap counts as reaching it"); + assert.equal(reg.checkUvtCap().remaining, 0); +}); + +test("no cap means never capped, whatever was spent", () => { + const reg = new ContextRegistry(); + reg.beginTurn("t1"); + reg.settleTurn("t1", 10_000_000); + const check = reg.checkUvtCap(); + assert.equal(check.capped, false); + assert.equal(check.cap, null); +}); + +test("local unmetered sessions are labelled, not counted as zero spend", () => { + const reg = new ContextRegistry(); + reg.markLocalUnmetered(); + assert.equal(reg.usageStatus(), "local-unmetered"); + assert.equal(reg.checkUvtCap().capped, false, "an unmetered session cannot exceed an Aether cap"); +}); + +test("usageStatus distinguishes unknown from observed", () => { + const reg = new ContextRegistry(); + assert.equal(reg.usageStatus(), "unknown"); + reg.beginTurn("t1"); + reg.settleTurn("t1", 5); + assert.equal(reg.usageStatus(), "observed"); +}); + +test("purge clears observed usage back to unknown, not to zero", () => { + const reg = new ContextRegistry(); + reg.beginTurn("t1"); + reg.settleTurn("t1", 5); + reg.purge(); + assert.equal(reg.uvtObserved, null); + assert.equal(reg.usageStatus(), "unknown"); +}); + +test("unmeasured headroom is null, never the full cap", () => { + // Reporting `remaining: cap` when nothing has been measured is the same false + // zero wearing a different hat — it tells the user their whole budget is + // intact when in fact none of it has been counted. + const reg = new ContextRegistry(); + reg.setUvtCap(1000); + const unknown = reg.checkUvtCap(); + assert.equal(unknown.remaining, null); + assert.notEqual(unknown.remaining, 1000, "the full cap is not a measurement of headroom"); + + reg.beginTurn("t1"); + reg.settleTurn("t1", 400); + assert.equal(reg.checkUvtCap().remaining, 600, "once measured, headroom is real"); +});