diff --git a/COMMANDS.md b/COMMANDS.md index 15bc58d..a6cc1e2 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -15,12 +15,12 @@ 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` -`help`, `models`, `model`, `agent`, `agents`, `tier`, `audit`, `effort`, `doctor`, `clear`, `exit`, `mcp`, `autonomous-execution`, `subagent-driven-execution`, `self-review`, `recon`, `plan`, `research`, `review`, `code-review`, `writing-skills`, `writing-plans`, `queue`, `steer`, `btw`, `pin`, `drop`, `snapshot`, `limit`, `audit-receipt`, `rollback`, `logs-view`, `goal`, `goals`, `memory`, `workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, `vault-tag`, `vault-tree`, `delegate`, `tree`, `broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, `revert`, `photogen`, `frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, `add`, `hud` +`help`, `models`, `model`, `agent`, `agents`, `tier`, `audit`, `effort`, `doctor`, `clear`, `exit`, `mcp`, `skills`, `why`, `autonomous-execution`, `subagent-driven-execution`, `self-review`, `recon`, `plan`, `research`, `review`, `code-review`, `writing-skills`, `writing-plans`, `queue`, `steer`, `btw`, `pin`, `drop`, `snapshot`, `limit`, `audit-receipt`, `rollback`, `logs-view`, `goal`, `goals`, `memory`, `workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, `vault-tag`, `vault-tree`, `delegate`, `tree`, `broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, `revert`, `photogen`, `frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, `add`, `hud` @@ -150,16 +150,47 @@ Exports the cryptographic proof package for one audit entry. Find ids with aether receipt chat_8f3a... ``` -### `aether doctor [--deep]` — runtime diagnostics -Runs an ordered set of structured checks — auth, network reachability, -config integrity, MCP registry health, and more — and prints a pass/fail -summary. `--deep` adds slower, bounded checks on top of the fast baseline. -Exits `1` if any check fails, so it's safe to gate scripts on. +### `aether doctor [--network] [--fix]` — runtime diagnostics +Runs an ordered set of structured checks — auth, config integrity, MCP +registry health, skill index/lock/trust, instruction conflicts, and more — +grouped by category with a pass/fail summary. The fast default makes no +network calls and no mutations; `--network` (alias `--deep`) adds slower, +bounded backend probes plus a capability-manifest fetch. Exits `1` if any +check fails, so it's safe to gate scripts on. + +Flags: `--category a,b` runs only those categories; `--failed` shows only +warn/fail checks (the summary still reflects the full run); `--junit ` +writes a JUnit XML report for CI. `--json` emits the stable v1 report shape +by default — pass `--schema v2` for the richer v2 schema (severity, +configured/reachable/verified state, repair ids). + +`--fix` prints a dry-run repair plan (rebuild corrupt skill stores, create a +missing config dir, remove stale temp files); `--fix --yes` applies it with +backup-first transactions and appends metadata-only receipts to +`repair-receipts.jsonl` in the config directory. ```bash aether doctor -aether doctor --deep -aether doctor --json +aether doctor --network +aether doctor --json # v1 shape +aether doctor --json --schema v2 # v2 shape +aether doctor --category skills,mcp --failed +aether doctor --junit doctor.xml +aether doctor --fix --yes +``` + +### `aether support-bundle` — redacted diagnostic archive +Packages a metadata-only diagnostic bundle as a single uncompressed `.tar` +in the current directory: fast doctor report, runtime/config summaries, +skill and instruction inventories (digests and counts, never content), and +the last 200 redacted session event lines, plus a hash manifest. The bundle +is reopened, secret-scanned, and hash-verified before it is finalized — on +any failure nothing is left behind and the command exits `1`. Tokens, env +values, file contents, and private absolute paths are never included. + +```bash +aether support-bundle +aether support-bundle --json ``` ### `aether mcp [list|doctor|repair]` — manage and diagnose MCP servers @@ -174,6 +205,26 @@ disconnect actions per entry. | `aether mcp doctor` | Same report; exits `1` if any check fails (scriptable health gate). | | `aether mcp repair` | Back up and reset a corrupted local MCP registry (confirms first). | +### `aether skills ` — agent skills +Discovers built-in, user (`~/.config/aether/skills/user/`), and project +(`.aether/skills/project/`) skills. Project skills are untrusted until you +inspect and trust them; trust binds to the exact content digest, so any change +requires re-trust. The REPL `/skills` command covers the read/toggle subset — +trust decisions stay in the CLI. + +| Subcommand | Does | +|---|---| +| `aether skills list` | Index of discovered skills: id, scope, trust, enabled, automatic, token cost. `--json` prints the raw index. | +| `aether skills show ` | Metadata, digest, and declared tool/permission policy (never the body). | +| `aether skills explain ` | `show` plus the files that would load and the effective tool policy. | +| `aether skills create [--scope project\|user]` | Scaffold a new skill directory (default: project). | +| `aether skills install [--scope project\|user]` | Validate and copy a local skill directory into the scope root. | +| `aether skills enable ` / `disable ` | Toggle a skill locally. | +| `aether skills trust ` | Show digest + requested permissions, then record trust (`--yes` or interactive y/N; fails closed without a TTY). | +| `aether skills untrust ` | Remove the local trust record. | +| `aether skills lock` | Write `.aether/skills.lock.json` for project skills (safe to commit). | +| `aether skills check [id\|--all] [--ci]` | Static checks: schema, lock drift, trust, dependency graph, eval fixtures. `--ci` exits `1` on failure; `--json` for structured output. | + ### `aether config [show|get|set]` — local settings Local settings, stored at `~/.config/aether/config.json`. ```bash @@ -216,6 +267,7 @@ mirrors the live registry in `src/commands/slash_registry.ts`. | `/doctor [deep]` | Run ordered diagnostics; `deep` adds bounded checks. | | `/clear` | Clear the screen. | | `/mcp [list|doctor|repair]` | Diagnose or confirmation-gated repair for MCP servers. | +| `/skills [list|show |enable |disable ]` | List and manage agent skills. Trust stays in the CLI: `aether skills trust `. | | `/exit`, `/quit` | Leave the REPL. | Typos get a nudge: `/modle` answers `did you mean /model?`. Tab completes any diff --git a/README.md b/README.md index b74b74d..98ec9e8 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Six frontier models, every one with a million-token window. In the GPT-5.6 famil |---|---| | **Free** | Claude Haiku 4.5 · DeepSeek V4 Flash · one image model as a teaser | | **Solo** | + Claude Sonnet 5 · GPT-5.4 mini · the **Neo 5.1T** orchestrator · the full image fleet | -| **Pro / Team** | + Claude Opus 4.8 · GPT-5.5 · DeepSeek V4 Pro · Kimi K2.6 · Gemma 4 31B · the six frontier models above · video & 3D generation · the **Kronus v2.4** and **Aether-Vision** orchestrators | +| **Pro / Team** | + the six frontier models above — **Claude Opus 5 · GPT-5.6 Sol / Terra / Luna · Kimi K3 · Gemini 3.6 Flash** — plus the previous generation, still fully selectable (Claude Opus 4.8 · GPT-5.5 · DeepSeek V4 Pro · Kimi K2.6 · Gemma 4 31B) · video & 3D generation · the **Kronus v2.4** and **Aether-Vision** orchestrators | Media is 15 image models (Nano Banana Pro & 2, FLUX.2 Klein / Pro / Flex / Max, Recraft V3 & V4, Seedream 4.5, Riverflow V2, GPT-5 Image), 16 video models (Seedance 2.0 & 1.5 Pro, Veo 3.1 / Fast / Lite, Kling 3.0 Standard & Pro, Kling Video O1, Sora 2 Pro, Wan 2.6 & 2.7, Hailuo 2.3, HunyuanVideo 1.5, Grok Imagine), and Hunyuan3D 2.1 for text-to-3D — all drivable from the prompt line with `/photogen`, `/videogen` and `/storyboard`. diff --git a/docs/releases/2026-08-skills-health-release-record.md b/docs/releases/2026-08-skills-health-release-record.md new file mode 100644 index 0000000..d75dcc8 --- /dev/null +++ b/docs/releases/2026-08-skills-health-release-record.md @@ -0,0 +1,59 @@ +# Skills & Health — Release Record (Aether Agent Release 5) + +Status: IN PROGRESS — fields marked TBD are filled at each rollout stage. +No secrets, customer content, or internal topology belong in this file. + +## Contract versions + +| Contract | Version | +|---|---| +| Skill schema (aether.skill/v1) | 1 | +| Skill context packet | 1 | +| Instruction context packet | 1 | +| Capability contract | 1 | +| Doctor report schema | 2 (v1 adapter preserved) | +| Support bundle schema | 1 | +| Dev-session protocol | 1 (additive fields only) | + +## Commits and digests + +- AETHER-CLOUD baseline at kickoff: `4e6b9e2b150de0dadf3b213fbd988987cd48a8b5` +- aether-agent baseline at kickoff: `b98ef26d16daf61a32a6c0ca437172d794b2efe1` +- PR A (cloud foundation, dark): AetherAI3/AETHER-CLOUD#1065 — merged SHA: `97eacd3e9aca4df226cae638f8f8868b8219fe88` (2026-08-15, deployed dark; required checks python/site/web green + desktop; site rerun after a runner font-fetch flake) +- PR B (agent release): TBD +- PR C (activation): TBD +- Capability contract canonical sha256: `8da094234a370a28dfd6206f039425f086307aa9ca0a67bc004d3d453716ac04` + (verified byte-identical between Python `contract_digest()` and the TS generator) +- npm package digest: TBD (at Stage 3) + +## Test counts (latest full runs) + +- aether-agent `npm test`: full suite green after the instruction-extraction fix (TBD exact count at PR B CI) +- AETHER-CLOUD targeted batch: 527 passed (agent_dev, agent_capabilities, capability routes, dev-session routes, flags, web_artifacts, OpenAPI snapshot) +- Site vitest: 2117/2118 locally (1 pre-existing Windows CRLF hash issue; linux CI green: TBD confirm on PR A) +- Desktop quick gates: 17/17 + +## Live proof (Stage 2 canary) + +TBD — record timestamps and outcomes for the §24 sequence (trust lifecycle, +undeclared-tool block, network block, nested AGENTS.md scope, conflict +resolution, doctor fast/network/live, safe repair, support-bundle canary scan, +digest parity across surfaces, installed-package smoke). + +## Known limits + +- No remote skill marketplace in v1; no skill scripts or hooks. +- Project skills require explicit digest-bound trust. +- Live provider proof is separately authorized (`--live --provider --max-uvt`). +- Cursor rule support covers the simple glob subset only; unsupported syntax + warns and the rule is not applied. +- Hosted skill context requires a server with capability contract v1; legacy + servers refuse with `skill.server_unsupported` unless `--no-skills`. + +## Rollback path + +- Disable hosted skill context: unset `AETHER_AGENT_SKILLS_ENABLED` (flag trio; per-user overrides available). +- Client falls back to local skills and the packaged capability snapshot automatically. +- Disable automatic selection while preserving explicit invocation: per-skill `aether skills disable`, or remove the local automatic opt-ins. +- Agent package rollback through the signed npm release channel. +- Rollback never rewrites a user's project skills or committed lock. diff --git a/docs/releases/SKILLS_HEALTH_CURRENT_STATE.md b/docs/releases/SKILLS_HEALTH_CURRENT_STATE.md new file mode 100644 index 0000000..3344272 --- /dev/null +++ b/docs/releases/SKILLS_HEALTH_CURRENT_STATE.md @@ -0,0 +1,67 @@ +# Skills & Health Release — aether-agent Current State + +Recorded: 2026-08-14. Baseline `origin/main` SHA: `b98ef26d16daf61a32a6c0ca437172d794b2efe1`. +Lane branch: `feat/skills-health` (worktree `~/aether-skills-wt`). + +## Overlap decision + +Open PRs at kickoff: #62/#63/#64 (dependabot GitHub Actions bumps), #36 (docs spec loop). +None touches skills, doctor, instructions, capability contracts, or dev sessions. +No competing branch owns this lane. Recently merged: bidirectional CloudBrain +(`feat/cloud-brain-bidirectional`, now in main as `b98ef26`) — this lane builds on top of it, +not around it. + +## Source-of-truth map (as of baseline) + +| Concern | Source of truth | +|---|---| +| CLI commands | `src/commands/cli_registry.ts` (`CLI_COMMANDS`, validated at import) | +| Slash commands | `src/commands/slash_registry.ts` + `handleSlash` switch in `src/commands/slash.ts`; parity tests parse the switch source (`test/slash_registry.test.ts`) and `COMMANDS.md` marker blocks (`test/command_docs_parity.test.ts`) | +| Tool names | `src/core/brain_protocol.ts` `TOOLS` (frozen 8: read_file, write_file, run_shell, run_tests, repo_search, git_commit, web_search, web_fetch) | +| Tool schemas + side-effect classes | `src/core/tool_registry.ts` (`TOOL_DEFINITIONS`, `ToolSideEffect = read\|write\|shell\|git\|network`, `validateToolDefinitionCoverage`) | +| Tool execution | `src/core/tool_executor.ts` (workspace escape guard `safe()`, output caps) | +| Permission modes | `src/types.ts` `PermissionMode = ask\|auto\|skip` + `autoApply`; gate logic `src/core/autonomy.ts` (`gateActionFor`, `decideGate`, fail-closed no-TTY) | +| Effort tiers | `src/ui/effort.ts` `EFFORT_TIERS = LOW/MED/HIGH/MAX/ULTRA/CODEPRO` | +| Bridge protocol | `src/core/brain_protocol.ts` `PROTOCOL_VERSION = 3`; fixture `test/fixtures/bridge_conformance.json`; docs `docs/CONTRACTS.md`, `docs/BRIDGE_PROTOCOL.md` | +| Dev-session wire | `src/core/brain_cloud.ts` `DEV_PROTOCOL_VERSION = 1`; request shape `src/core/envelope.ts` `DevSessionWireRequest` | +| Config root | `src/core/config.ts` `configDir()` = `$AETHER_CONFIG_DIR` ?? `~/.config/aether` (all platforms) | +| Logs root | `~/.aether-agent/logs` (`src/core/session_log.ts`) | +| Doctor | `src/commands/doctor.ts` (30 lines) + `src/core/diagnostics.ts` (12 hardcoded checks, `schemaVersion: 1`, `--deep`) | +| Redaction | module-private helpers in `src/core/session_log.ts` (`redactInline`, `loggedArgs`, `loggedEvent`) | +| Release policy | `scripts/verify-production.ts` (zero runtime deps, exact pack allowlist, size ≤ 5 MB, SHA-pinned actions) | + +## What does NOT exist yet (greenfield for this release) + +- No local skill system: no loader, no `SKILL.md`, no skills directory, no `/skills`, no `aether skills`. +- No instruction-file reading: zero references to `AGENTS.md` / `CLAUDE.md` anywhere in src. +- No support bundle; redaction primitives exist but are private to `session_log.ts`. +- No capability manifest or generated contract files. +- No check registry inside doctor — checks are an inline array in `diagnosticReport()`. + +## Behavioral memory vs Agent Skills + +The existing "skills" are cloud-hosted QOPC procedural memory rows +(`src/core/cloud_memory.ts`, memory tier `procedural` in `src/core/memory.ts`, +wire event `{type:"skill"}` in `brain_protocol.ts`). They are learned behavioral +descriptions with no version, no digest, no tool policy, no trust state, and no +invocation path. This release does NOT rename or remove them. They remain +"behavioral memory". "Agent Skills" in this release means the new versioned, +digest-bound, permission-declared, invokable packages defined by +`aether.skill/v1`. UI copy must keep the two distinct. + +## Constraints inherited from the repo + +- Zero runtime dependencies (verify-production hard-fails otherwise) — so strict JSON metadata, no YAML parser. +- npm pack allowlist is exact: new built-in skill resources must ship under `dist/src/**` (compiled-adjacent copy step or embedded strings) — `files` changes must update `scripts/verify-production.ts` expectations deliberately. +- Tests are TS compiled to `dist/test/**`; fixtures referenced relative to dist need `../../` hops. +- tsconfig is maximally strict (`noPropertyAccessFromIndexSignature` etc.). +- Adding a command touches 4 places: registry, dispatch switch, `COMMANDS.md` marker block, tests. +- `capabilities: TOOLS` currently sent unfiltered on dev-session create — insertion point for skill-scoped capability sets; shape changes need additive versioned fields, keep `DEV_PROTOCOL_VERSION = 1` negotiation intact. +- Known gap to fix per spec §7.3: `web_search`/`web_fetch` have `sideEffect: "network"` which maps to no gate (`gateActionFor` returns null) — network currently bypasses the mutation gate. +- CI: `.github/workflows/ci.yml` runs on GitHub-hosted `ubuntu-latest` + `windows-latest` in this repo (matrix build-and-test + supply-chain job). CodeQL weekly. Release workflow publishes with provenance. Any workflow edit must keep: explicit permissions, 40-hex SHA pins, `npm ci --ignore-scripts`, runs-on/timeout parity — enforced by `verify-production` and `test/production_hardening.test.ts`. + +## Dependency order for this lane + +1. PR A (AETHER-CLOUD): capability contract, hosted skill/instruction context validation, ack frames, feature flag — deployed dark. +2. PR B (aether-agent, this worktree): skill engine, instructions, doctor v2, safe repair, support bundle, capability fallback pinned to PR A's merged SHA. +3. PR C: activation + release proof. diff --git a/docs/skills/AUTHORING.md b/docs/skills/AUTHORING.md new file mode 100644 index 0000000..175c686 --- /dev/null +++ b/docs/skills/AUTHORING.md @@ -0,0 +1,69 @@ +# Authoring Agent Skills + +An Agent Skill is a versioned, digest-bound instruction package. It never +grants authority: the effective tool set is always the intersection of the +operator's session policy, the skill's declarations, and the workspace +boundary. A missing declaration fails closed. + +## Layout + +``` +.aether/skills/project// project skill (untrusted until trusted) + skill.json aether.skill/v1 manifest (strict JSON) + SKILL.md instructions (loaded only on invocation) + references/ optional resources (must be declared) + evals/cases.json offline eval cases (recommended) +``` + +User skills live under `/skills/user//` (default +`~/.config/aether/skills/user`). Built-ins ship inside the npm package under +the reserved `aether/*` namespace. + +## Manifest rules (aether.skill/v1) + +- `schema_version` must be `1`; unknown keys are hard errors. +- `id` is `/`; scope must match where the skill lives. +- `version` is strict semver `MAJOR.MINOR.PATCH`. +- `tools.required ⊆ tools.allowed`; `tools.denied ∩ tools.allowed = ∅`; + only canonical tool names (read_file, write_file, run_shell, run_tests, + repo_search, git_commit, web_search, web_fetch). +- Permissions come from the closed vocabulary (`workspace.read`, + `shell.test`, `network.general`, …). `workspace.outside`, `secrets.read`, + and `billing.spend` cannot be declared by any skill. +- All resource paths are relative, no `..`, no absolute paths, no URLs. +- Resources are loaded ONLY if listed in `context.resources`. + +## Digest and trust + +One canonical SHA-256 covers the normalized manifest, `SKILL.md`, and every +declared resource/eval file. Trust binds to that digest: change one byte and +the skill returns to `changed · review required`. `aether skills trust ` +records trust locally (never in the repo); `aether skills lock` writes the +committed-safe `.aether/skills.lock.json` (digests only, no trust). + +## Lifecycle commands + +``` +aether skills create --scope project|user +aether skills check | --all [--ci] +aether skills eval | --all [--json] [--junit ] +aether skills trust # inspect digest + permissions, then confirm +aether agent --skill "task" # explicit invocation +``` + +Automatic selection requires `triggers.automatic: true` AND (for user/project +skills) an explicit local opt-in — and project skills must be trusted. At most +3 automatic skills load per turn; skill bodies are lazy-loaded only after +selection. + +## Evals + +`evals/cases.json` is a JSON array of cases: + +```json +{ "id": "denies-undeclared-write", "input": "…", + "expected": { "selected_skill": "project/x", "allowed_tools": ["read_file"], + "forbidden_tools": ["write_file"], "max_uvt": 0 } } +``` + +Offline evals are zero-spend by construction — `max_uvt` must be 0. diff --git a/docs/skills/CAPABILITIES.md b/docs/skills/CAPABILITIES.md new file mode 100644 index 0000000..c941b02 --- /dev/null +++ b/docs/skills/CAPABILITIES.md @@ -0,0 +1,27 @@ +# Capability contract + +One canonical contract drives every surface: backend, web, desktop, CLI help, +and the public matrix. There is no second hand-maintained table. + +- Canonical source: `AETHER-CLOUD contracts/agent-capabilities.v1.json` + (JSON Schema at `contracts/schemas/agent-capabilities.v1.schema.json`). +- Server endpoint: `GET /agent/capabilities` → `{contract, digest, overlay}`. + The overlay carries runtime availability (feature flags) and never mutates + the static contract. +- Generated mirrors (drift-gated in CI): site TS, desktop CJS, Python + constants, and this CLI's offline fallback + (`src/generated/agent_capabilities.ts`, pinned to source repo + commit + + canonical sha256). +- Digest: sha256 over the canonical JSON encoding (sorted keys, compact + separators) — identical bytes in Python and TypeScript, verified by parity + tests. + +Client resolution order: +1. Server manifest when reachable and same major contract version. +2. Packaged fallback otherwise, with a visible warning — never a silent mix + of two vocabularies. +3. An incompatible major version keeps the fallback and says so. + +`aether capabilities [--json] [--available]` shows static support separately +from runtime availability. The public matrix at `/capabilities` renders only +generated data and marks unreleased features unavailable. diff --git a/docs/skills/DOCTOR.md b/docs/skills/DOCTOR.md new file mode 100644 index 0000000..b241afe --- /dev/null +++ b/docs/skills/DOCTOR.md @@ -0,0 +1,46 @@ +# Doctor v2 + +`aether doctor` — health with proof, three state words per check: +`configured`, `reachable`, `verified now`. No single green light from +configuration alone. + +## Modes + +| Mode | Cost | What runs | +|---|---|---| +| `aether doctor` | zero — no network, no mutation, no model call, no UVT | runtime, workspace, git, config/transport, auth config, tool schemas + gates, skills index/lock/trust/evals, instruction graph + conflicts, memory, MCP registry, local persistence | +| `--network` (alias `--deep`) | bounded read-only network | + backend catalog, capability manifest, MCP broker | +| `--live` | zero UVT (synthetic server contract) | one real dev session end to end: auth, capability negotiation, sequence-numbered frames, pause/resume acks, sandboxed write→read tool round trip executed by this host, tool-result acks, teardown, no-residue check | +| `--fix` | dry-run by default | prints the repair plan; `--fix --yes` applies | + +Filters and output: `--category skills,instructions`, `--failed`, +`--junit `, `--json` (v1 shape by default for existing consumers; +`--schema v2` for the native report). Exit 1 when any check fails. + +## Safe repair + +Only three repair classes exist, all backup-first and reversible: +rebuild a corrupt local skill index (settings/trust stores), create a missing +config directory (0700), prune stale temp files (>1 day, listed first). +Every mutation follows inspect → plan → show target → back up → atomic +mutate → verify → rollback on failure, and appends a metadata-only receipt +to `/repair-receipts.jsonl`. Doctor never touches credentials, +git state, source files, dependencies, or network policy. + +# Support bundle + +`aether support-bundle` builds a diagnostics archive that contains metadata, +never customer work: + +- `support-manifest.json` (per-file sha256), `doctor-report.json` (fast, v2), + `runtime.json`, `sanitized-config.json` (host only, no token), + `skill-inventory.json` / `instruction-inventory.json` (ids, digests, trust, + sizes — no bodies), `recent-redacted-events.ndjson`, `README.txt`. +- Excluded by construction: repository source, diffs, prompts, transcripts, + tool output, instruction/skill text, tokens, env values, private paths. +- Verification before success: staged in a 0700 temp dir, packed with a + deterministic minimal tar, reopened and re-parsed, entry allowlist + enforced, canonical secret scanner run over every entry, per-file hashes + verified — then renamed into place and the final sha256 printed. Any + failure deletes the candidate and exits 1. Nothing is ever uploaded + automatically. diff --git a/docs/skills/INSTRUCTIONS.md b/docs/skills/INSTRUCTIONS.md new file mode 100644 index 0000000..d088b46 --- /dev/null +++ b/docs/skills/INSTRUCTIONS.md @@ -0,0 +1,30 @@ +# Instruction files (AGENTS.md and compatibility) + +The agent reads these sources, all as bounded, read-only text guidance with +visible provenance — never as executable configuration: + +| Source | Scope | +|---|---| +| `.aether/instructions.md` | whole project (canonical, highest file precedence after nested) | +| `AGENTS.md` (root) | whole project | +| `AGENTS.md` (nested) | only files inside its directory subtree | +| `/instructions.md` | user-level | +| `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md` | compatibility imports | +| `.cursor/rules/*.mdc` | compatibility import, glob-scoped | + +Precedence (higher wins): current operator turn > current explicit skill > +nearest nested AGENTS.md > `.aether/instructions.md` > root AGENTS.md > +user-level > compatibility imports. A lower source adds non-conflicting +guidance; it never erases a higher one. + +Cursor rules: only the simple glob subset (`*`, `**`, `?`, comma lists) is +supported. Unsupported matching syntax produces a visible warning and the rule +is NOT applied (never silently applied globally). + +Conflicts (e.g. two different test commands) are detected and reported with +the effective winner and the reason. Files over 64 KiB are truncated with a +warning; binary/invalid-encoding files are skipped with a reason. + +Safety: instruction files cannot run commands, approve URLs, claim +permissions, or import anything outside the project. Their content rides to +the brain as fenced, provenance-labeled data. 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/chat.ts b/src/commands/chat.ts index 43d2c15..a98f12e 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -214,7 +214,7 @@ async function runLocalTurn(ctx: AppContext, prompt: string): Promise { process.stderr.write(`blocked ${name}: confirmation required; use --yes or permissionMode skip\n`); return false; } - const detail = String(args["path"] ?? args["command"] ?? args["message"] ?? ""); + const detail = String(args["path"] ?? args["command"] ?? args["message"] ?? args["url"] ?? args["query"] ?? ""); const shown = detail.length > 120 ? detail.slice(0, 117) + "..." : detail; return ctx.confirm(`\nwarning ${name}${shown ? " " + shown : ""} - run it? [y/N] `); }; diff --git a/src/commands/cli_registry.ts b/src/commands/cli_registry.ts index dcda2c3..28d186b 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: "doctor", args: "[--network] [--fix]", 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/code.ts b/src/commands/code.ts index 11382d7..450f4c4 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -40,6 +40,9 @@ import { createWorktree, mergeHint, type Worktree } from "../core/worktree.js"; import { parseRepoSpec, ensureLocalClone, prCreateHint, type RepoSpec } from "../core/repo.js"; import { chooseBackend } from "../core/backend.js"; import { decideGate } from "../core/autonomy.js"; +import { prepareSkillSession, SkillError, type SkillSession } from "../core/skills/skill_session.js"; +import { defaultPermissionEnvelope, refuseUndeclaredToolCall } from "../core/skills/skill_policy.js"; +import { recordWhy } from "../core/why_log.js"; export { prepareWorkspace } from "./code_support.js"; @@ -69,6 +72,11 @@ export interface CodeOpts { worktree?: boolean; /** Work on a GitHub repo (owner/name): clone via gh/git, then worktree it. */ repo?: string; + /** Explicitly invoke one skill (id, unique short name, or command alias). */ + skill?: string; + /** Deliberately run without skills/instructions (also unlocks the legacy + * server fallback when skill context would otherwise refuse it). */ + noSkills?: boolean; } const nowIso = (): string => new Date().toISOString(); @@ -176,7 +184,36 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr } const brainKind: "local" | "cloud" = goLocal ? "local" : "cloud"; - const brain: Brain = goLocal ? new LocalBrain() : new CloudBrain(ctx.api); + // Skills & instructions for this run. Refusals (untrusted project skill, + // ambiguous name, budget) abort with the stable code + one next action — + // never a silent skill-free downgrade. + let session: SkillSession; + try { + session = prepareSkillSession({ + projectRoot: cwd, + prompt: task, + ...(opts.skill ? { explicitSkill: opts.skill } : {}), + ...(opts.noSkills ? { noSkills: true } : {}), + }); + } catch (err) { + if (err instanceof SkillError) { + recordWhy("skill-refusal", err.refusal.code + ": " + err.refusal.detail); + process.stderr.write(`✗ ${err.refusal.code}: ${err.refusal.detail}\n`); + return 1; + } + throw err; + } + for (const line of session.headerLines) process.stderr.write(line + "\n"); + + const brain: Brain = goLocal + ? new LocalBrain() + : new CloudBrain(ctx.api, { + ...(session.packet ? { skillContext: session.packet as unknown as Record } : {}), + ...(session.instructionPacket + ? { instructionContext: session.instructionPacket as unknown as Record } + : {}), + ...(opts.noSkills ? { allowLegacyWithoutSkills: true } : {}), + }); const exec = new ToolExecutor(cwd, opts.testCmd); // Scope the session manifest to the ORIGINAL launch directory (ctx.flags.cwd), // not the possibly-substituted `cwd` (an auto-created worktree, or a manually @@ -220,6 +257,15 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr // non-TTY (CI/pipe) an un-pre-approved call FAILS CLOSED rather than running // unattended. `--yes` or `permissionMode: skip` opt out. const gate: ToolGate = async ({ name, args }) => { + // Skill policy first (declared tools ∩ operator envelope) — a refusal here + // is structural, not a prompt: the brain asked for something no active + // skill declared, so no amount of user confirmation makes it declared. + const refusal = refuseUndeclaredToolCall(name, session.policies, defaultPermissionEnvelope()); + if (refusal) { + recordWhy("permission-denial", refusal.code + ": " + refusal.detail); + process.stderr.write(`✗ ${refusal.code}: ${refusal.detail}\n`); + return false; + } const outcome = decideGate(name, ctx.cfg.permissionMode, ctx.cfg.autoApply, { yes: ctx.flags.yes, isTty: Boolean(process.stdin.isTTY), @@ -232,7 +278,7 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr ); return false; } - const detail = String(args["command"] ?? args["path"] ?? args["message"] ?? ""); + const detail = String(args["command"] ?? args["path"] ?? args["message"] ?? args["url"] ?? args["query"] ?? ""); const shown = detail.length > 200 ? detail.slice(0, 197) + "…" : detail; return ctx.confirm(`\n⚠ ${name}${shown ? ` ${shown}` : ""} — run it? [y/N] `); }; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3d95037..227f66d 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,15 +1,97 @@ +// aether doctor — structured diagnostics plus safe repair. +// +// `--json` stays v1-shaped by default for existing consumers; pass +// `--schema v2` for the native report. `--deep` remains an alias of +// `--network`. `--fix` prints a dry-run plan; `--fix --yes` applies it. + +import { writeFileSync } from "node:fs"; import type { Writable } from "node:stream"; import type { AppContext } from "../core/context.js"; import { - diagnosticReport, - renderDiagnosticReport, + doctorReportV2, + toV1Report, + renderDoctorJUnit, + renderDoctorReport, type DiagnosticDependencies, + type DoctorReportV2, } from "../core/diagnostics.js"; +import { executeRepairs, planRepairs, repairReceiptsPath } from "../core/diagnostics/repair.js"; export interface DoctorCommandOptions { out?: Writable; dependencies?: DiagnosticDependencies; deep?: boolean; + network?: boolean; + live?: boolean; + failed?: boolean; + fix?: boolean; + yes?: boolean; + schema?: string; + category?: string; + junit?: string; +} + +const USAGE = + "usage: aether doctor [--network|--deep] [--live] [--category a,b] [--failed]\n" + + " [--junit ] [--schema v1|v2] [--fix [--yes]] [--json]\n" + + " --json emits the v1 report shape unless --schema v2 is passed\n" + + " --live drives one synthetic dev session end to end (no model call, zero UVT)\n"; + +/** Parse slash/CLI tokens on top of pre-parsed options; null = usage error. */ +function parseDoctorArgv(argv: string[], base: DoctorCommandOptions): DoctorCommandOptions | null { + const opts = { ...base }; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]!; + const valueOf = (flag: string): string | undefined => + arg.startsWith(flag + "=") ? arg.slice(flag.length + 1) : argv[++index]; + if (arg === "deep" || arg === "--deep" || arg === "network" || arg === "--network") opts.network = true; + else if (arg === "--live" || arg === "live") opts.live = true; + else if (arg === "--failed" || arg === "failed") opts.failed = true; + else if (arg === "--fix" || arg === "fix") opts.fix = true; + else if (arg === "--yes" || arg === "-y") opts.yes = true; + else if (arg === "--schema" || arg.startsWith("--schema=")) opts.schema = valueOf("--schema"); + else if (arg === "--category" || arg.startsWith("--category=")) opts.category = valueOf("--category"); + else if (arg === "--junit" || arg.startsWith("--junit=")) opts.junit = valueOf("--junit"); + else return null; + } + return opts; +} + +function runFix(ctx: AppContext, out: Writable, opts: DoctorCommandOptions): number { + const now = opts.dependencies?.now ?? new Date().toISOString(); + const plans = planRepairs(now); + if (plans.length === 0) { + out.write("nothing to repair.\n"); + return 0; + } + out.write("repair plan (" + plans.length + " target(s)):\n"); + for (const plan of plans) { + out.write( + " " + plan.repairId + " [" + plan.targetClass + "]: " + plan.detail + "\n" + + " target: " + plan.target + "\n" + + (plan.backupPath ? " backup: " + plan.backupPath + "\n" : ""), + ); + } + const apply = opts.yes === true || ctx.flags.yes; + if (!apply) { + out.write("dry run — re-run with --fix --yes to apply.\n"); + return 0; + } + const outcomes = executeRepairs(plans, now); + let failed = 0; + for (const outcome of outcomes) { + if (outcome.verified) { + out.write(" ✓ " + outcome.plan.repairId + ": " + outcome.plan.target + " repaired\n"); + } else { + failed += 1; + out.write( + " ✗ " + outcome.plan.repairId + ": " + outcome.plan.target + + (outcome.rolledBack ? " — verify failed, rolled back" : " — repair failed") + "\n", + ); + } + } + out.write("receipts: " + repairReceiptsPath() + "\n"); + return failed > 0 ? 1 : 0; } export async function cmdDoctor( @@ -17,14 +99,56 @@ export async function cmdDoctor( argv: string[] = [], options: DoctorCommandOptions = {}, ): Promise { - const unknown = argv.filter((arg) => arg !== "deep" && arg !== "--deep"); - if (unknown.length) { - (options.out ?? process.stdout).write("usage: aether doctor [--deep] [--json]\n"); + const out = options.out ?? process.stdout; + const opts = parseDoctorArgv(argv, options); + if (!opts) { + out.write(USAGE); return 2; } - const deep = options.deep === true || argv.includes("deep") || argv.includes("--deep"); - const report = await diagnosticReport(ctx, deep, options.dependencies); - const out = options.out ?? process.stdout; - out.write(ctx.flags.json ? JSON.stringify(report) + "\n" : renderDiagnosticReport(report)); + const schema = (opts.schema ?? "v1").toLowerCase(); + if (schema !== "v1" && schema !== "1" && schema !== "v2" && schema !== "2") { + out.write(USAGE); + return 2; + } + + if (opts.fix) return runFix(ctx, out, opts); + + if (opts.live) { + const { runLiveProbe } = await import("../core/diagnostics/dev_session_live.js"); + out.write("Aether Doctor v2 · live (synthetic — no model call, zero UVT)\n\n"); + const probe = await runLiveProbe(ctx.api); + for (const stepResult of probe.steps) { + out.write(" " + (stepResult.ok ? "✓" : "✗") + " " + stepResult.id.padEnd(20) + stepResult.detail + "\n"); + } + out.write("\n" + (probe.ok ? "live probe verified" : "live probe FAILED") + "\n"); + if (ctx.flags.json) out.write(JSON.stringify({ schema_version: 2, mode: "live", ...probe }) + "\n"); + return probe.ok ? 0 : 1; + } + + const mode = opts.network === true || opts.deep === true ? "network" : "fast"; + const categories = (opts.category ?? "") + .split(",") + .map((token) => token.trim()) + .filter(Boolean); + const report = await doctorReportV2( + ctx, + { mode, ...(categories.length ? { categories } : {}) }, + opts.dependencies, + ); + + if (opts.junit) writeFileSync(opts.junit, renderDoctorJUnit(report), "utf8"); + + // --failed narrows the emitted checks; the summary (and exit code) still + // reflect the full run so a filtered view never hides a failure count. + const emitted: DoctorReportV2 = opts.failed + ? { ...report, checks: report.checks.filter((check) => check.status === "warn" || check.status === "fail") } + : report; + + if (ctx.flags.json) { + const payload = schema === "v2" || schema === "2" ? emitted : toV1Report(emitted); + out.write(JSON.stringify(payload) + "\n"); + } else { + out.write(renderDoctorReport(emitted)); + } return report.summary.fail > 0 ? 1 : 0; } 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/slash.ts b/src/commands/slash.ts index 9a02977..e3a0ccf 100644 --- a/src/commands/slash.ts +++ b/src/commands/slash.ts @@ -231,6 +231,16 @@ export async function handleSlash( await mcpFromRepl(ctx); break; } + case "skills": { + const { skillsSlash } = await import("./skills.js"); + await skillsSlash(ctx, out, arg); + break; + } + case "why": { + const { renderWhy } = await import("../core/why_log.js"); + out.write(renderWhy()); + break; + } case "delegate": { await delegateSlash(ctx, out, arg); break; diff --git a/src/commands/slash_registry.ts b/src/commands/slash_registry.ts index 8aa3361..25b4165 100644 --- a/src/commands/slash_registry.ts +++ b/src/commands/slash_registry.ts @@ -53,6 +53,8 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "clear", summary: "clear screen", section: "Session" }, { name: "exit", aliases: ["quit"], summary: "leave the REPL", section: "Session" }, { name: "mcp", args: "[list|doctor|repair]", summary: "manage and diagnose MCP servers", section: "Session" }, + { name: "skills", args: "[list|show |enable |disable ]", summary: "list and manage agent skills", section: "Session" }, + { name: "why", summary: "explain the latest skill selection, refusal, or conflict", section: "Session" }, // ── Agent Modes (prompt rewrites) ── { name: "autonomous-execution", args: "", summary: "execute without asking", section: "Agent Modes" }, 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/core/autonomy.ts b/src/core/autonomy.ts index 62e0fd0..a78ac6b 100644 --- a/src/core/autonomy.ts +++ b/src/core/autonomy.ts @@ -10,7 +10,7 @@ import type { PermissionMode } from "../types.js"; import { toolDefinition } from "./tool_registry.js"; -export type GateAction = "edit" | "shell" | "write"; +export type GateAction = "edit" | "shell" | "write" | "network"; export interface GateDecision { allowed: boolean; @@ -38,11 +38,15 @@ export function evaluate( * Map a tool name to the gate action it requires, or null when the tool is * read-only and never needs approval. `run_tests` is gated the same as * `run_shell` / `git_commit` (sideEffect: "shell") — it is NOT ungated. + * Network tools (`web_search` / `web_fetch`) are gated as "network" — an + * explicit permission category; they must never bypass the mutation gate + * simply because they mutate nothing locally. */ export function gateActionFor(tool: string): GateAction | null { const effect = toolDefinition(tool)?.sideEffect; if (effect === "write") return "write"; if (effect === "shell" || effect === "git") return "shell"; + if (effect === "network") return "network"; return null; } diff --git a/src/core/brain_cloud.ts b/src/core/brain_cloud.ts index 9ae337f..c8703eb 100644 --- a/src/core/brain_cloud.ts +++ b/src/core/brain_cloud.ts @@ -50,6 +50,17 @@ interface DevSessionCreated { tools?: string[]; } +/** Skills & Health context riding on dev-session create (contract v1). */ +export interface DevSessionContext { + /** Serialized SkillContextPacket (context_packet.ts). */ + skillContext?: Record; + /** Serialized InstructionContextPacket (instruction_resolver.ts). */ + instructionContext?: Record; + /** Operator explicitly agreed to run WITHOUT skills on a legacy server + * (after being shown what would be omitted). Default false: fail honest. */ + allowLegacyWithoutSkills?: boolean; +} + export class CloudBrain implements Brain { private aborted = false; private net: AbortController | null = null; @@ -58,7 +69,10 @@ export class CloudBrain implements Brain { /** Serializes upstream result POSTs so they arrive in execution order. */ private upstream: Promise = Promise.resolve(); - constructor(private readonly api: ApiClient) {} + constructor( + private readonly api: ApiClient, + private readonly devContext: DevSessionContext = {}, + ) {} run(task: TaskCommand): AsyncIterable { const queue = new EventQueue(); @@ -69,6 +83,7 @@ export class CloudBrain implements Brain { private async pump(task: TaskCommand, queue: EventQueue): Promise { try { let created: DevSessionCreated | null = null; + const hasSkillContext = Boolean(this.devContext.skillContext || this.devContext.instructionContext); try { created = await this.api.postJson( DEV_SESSIONS_PATH, @@ -78,10 +93,24 @@ export class CloudBrain implements Brain { effort: task.effort, capabilities: TOOLS, protocolVersion: DEV_PROTOCOL_VERSION, + ...(this.devContext.skillContext ? { skillContext: this.devContext.skillContext } : {}), + ...(this.devContext.instructionContext ? { instructionContext: this.devContext.instructionContext } : {}), }), ); } catch (err) { if (isLegacyServer(err)) { + // NEVER silently run as though the server honored skill context. + // A legacy server cannot consume it; refuse unless the operator + // deliberately opted into a skill-free run. + if (hasSkillContext && !this.devContext.allowLegacyWithoutSkills) { + queue.push({ + type: "error", + msg: + "skill.server_unsupported: hosted server does not support Agent Skills — " + + "run locally, or re-run with --no-skills to proceed without skill context", + }); + return; + } await this.legacyPump(task, queue); return; } @@ -294,6 +323,24 @@ function mapDevFrame(f: StreamFrame): BrainEvent | null { return { type: "monologue", text: f.text, depth: 1 }; case "delta": return { type: "monologue", text: f.text, depth: 0 }; + case "skill_context_ack": + return { + type: "status", + phase: f.accepted + ? `skills accepted (${f.count ?? 0} skill${(f.count ?? 0) === 1 ? "" : "s"})` + : `skills rejected: ${f.reason ?? "unspecified"}`, + poolUsed: 0, + poolCap: 0, + }; + case "instruction_context_ack": + return { + type: "status", + phase: f.accepted + ? `instructions accepted (${f.count ?? 0} source${(f.count ?? 0) === 1 ? "" : "s"})` + : `instructions rejected: ${f.reason ?? "unspecified"}`, + poolUsed: 0, + poolCap: 0, + }; case "error": return { type: "error", msg: f.msg }; case "done": 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/diagnostics.ts b/src/core/diagnostics.ts index 3eb7686..d9eea51 100644 --- a/src/core/diagnostics.ts +++ b/src/core/diagnostics.ts @@ -1,306 +1,35 @@ -import { accessSync, constants, existsSync, statSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import type { AppContext } from "./context.js"; -import { configDir } from "./config.js"; -import { isCredentialSafeUrl, MODELS_PATH } from "./transport.js"; -import { gateActionFor } from "./autonomy.js"; -import { validateToolDefinitionCoverage } from "./tool_registry.js"; -import { localMemoryReport, type MemoryRoots } from "./memory.js"; -import { LocalMcpStore } from "./mcp_store.js"; -import { McpClient } from "./mcp.js"; -import { bounded, collectMcpDiagnostics } from "./mcp_diagnostics.js"; - -export const DIAGNOSTIC_CONCURRENCY = 8; - -export type DiagnosticStatus = "pass" | "warn" | "fail" | "skip"; - -export interface DiagnosticCheck { - id: string; - category: string; - status: DiagnosticStatus; - detail: string; - durationMs: number; -} - -export interface DiagnosticReport { - schemaVersion: 1; - generatedAt: string; - deep: boolean; - checks: DiagnosticCheck[]; - summary: Record; -} - -export interface DiagnosticCheckSpec { - id: string; - category: string; - run(): Promise<{ status: DiagnosticStatus; detail: string }> | { status: DiagnosticStatus; detail: string }; -} - -export interface DiagnosticDependencies { - now?: string; - mcpClient?: McpClient; - mcpStore?: LocalMcpStore; - memoryRoots?: MemoryRoots; - timeoutMs?: number; -} - -function monotonicMs(): number { - return Number(process.hrtime.bigint()) / 1_000_000; -} - -export async function executeDiagnosticChecks( - specs: DiagnosticCheckSpec[], - concurrency = DIAGNOSTIC_CONCURRENCY, -): Promise { - const width = Math.max(1, Math.min(Math.floor(concurrency), 32)); - const results: DiagnosticCheck[] = new Array(specs.length); - let cursor = 0; - const worker = async (): Promise => { - for (;;) { - const index = cursor; - cursor += 1; - if (index >= specs.length) return; - const spec = specs[index]!; - const started = monotonicMs(); - try { - const result = await spec.run(); - results[index] = { - id: spec.id, - category: spec.category, - status: result.status, - detail: result.detail, - durationMs: Math.max(0, monotonicMs() - started), - }; - } catch { - results[index] = { - id: spec.id, - category: spec.category, - status: "fail", - detail: "check failed safely", - durationMs: Math.max(0, monotonicMs() - started), - }; - } - } - }; - await Promise.all(Array.from({ length: Math.min(width, specs.length) }, () => worker())); - return results; -} - -function nearestExisting(path: string): string { - let current = resolve(path); - while (!existsSync(current) && dirname(current) !== current) current = dirname(current); - return current; -} - -export async function diagnosticReport( - ctx: AppContext, - deep: boolean, - dependencies: DiagnosticDependencies = {}, -): Promise { - const timeoutMs = Math.max(100, Math.min(dependencies.timeoutMs ?? 2000, 10000)); - const mcpStore = dependencies.mcpStore ?? new LocalMcpStore(); - const mcpClient = dependencies.mcpClient ?? new McpClient(ctx.api); - const specs: DiagnosticCheckSpec[] = [ - { - id: "runtime.node", - category: "runtime", - run: () => { - // Must track package.json's engines.node — this project's own test - // script needs Node 24 (`--test --test-isolation` support). - const MIN_SUPPORTED_NODE_MAJOR = 24; - const major = Number(process.versions.node.split(".")[0]); - return { - status: major >= MIN_SUPPORTED_NODE_MAJOR ? "pass" : "fail", - detail: - major >= MIN_SUPPORTED_NODE_MAJOR - ? "Node runtime supported" - : `Node ${MIN_SUPPORTED_NODE_MAJOR} or newer required`, - }; - }, - }, - { - id: "workspace.directory", - category: "workspace", - run: () => { - const target = resolve(ctx.flags.cwd); - const valid = existsSync(target) && statSync(target).isDirectory(); - return { - status: valid ? "pass" : "fail", - detail: valid ? "workspace directory available" : "workspace directory unavailable", - }; - }, - }, - { - id: "workspace.git", - category: "workspace", - run: () => ({ - status: existsSync(join(ctx.flags.cwd, ".git")) ? "pass" : "warn", - detail: existsSync(join(ctx.flags.cwd, ".git")) - ? "git metadata detected" - : "workspace is not a git checkout", - }), - }, - { - id: "configuration.transport", - category: "configuration", - run: () => ({ - status: isCredentialSafeUrl(ctx.cfg.baseUrl) ? "pass" : "fail", - detail: isCredentialSafeUrl(ctx.cfg.baseUrl) - ? "backend URL transport is credential-safe" - : "backend URL transport is unsafe", - }), - }, - { - id: "authentication", - category: "auth", - run: async () => { - const configured = Boolean(await ctx.tokens.get()); - return { - status: configured ? "pass" : "warn", - detail: configured ? "credential configured" : "signed out", - }; - }, - }, - { - id: "backend.catalog", - category: "backend", - run: async () => { - if (!deep) return { status: "skip", detail: "deep check not requested" }; - try { - await bounded(ctx.api.getJson(MODELS_PATH), timeoutMs); - return { status: "pass", detail: "bounded catalog request succeeded" }; - } catch { - return { status: "warn", detail: "backend catalog unavailable" }; - } - }, - }, - { - id: "tools.schemas", - category: "tools", - run: () => { - const errors = validateToolDefinitionCoverage(); - return { - status: errors.length ? "fail" : "pass", - detail: errors.length ? "tool schema coverage incomplete" : "all protocol tools have schemas", - }; - }, - }, - { - id: "tools.gates", - category: "tools", - run: () => { - const valid = - gateActionFor("write_file") === "write" && - gateActionFor("run_shell") === "shell" && - gateActionFor("run_tests") === "shell" && - gateActionFor("git_commit") === "shell" && - gateActionFor("read_file") === null; - return { - status: valid ? "pass" : "fail", - detail: valid ? "side-effect gates are fail-closed" : "tool gate mapping drifted", - }; - }, - }, - { - id: "memory.health", - category: "memory", - run: () => { - const report = localMemoryReport( - ctx.flags.cwd, - undefined, - dependencies.memoryRoots, - dependencies.now, - ); - const sources = report.tiers.flatMap((tier) => tier.sources); - const failed = sources.some((source) => source.status === "degraded"); - const unscoped = sources.reduce((sum, source) => sum + source.unscoped, 0); - return { - status: failed ? "warn" : "pass", - detail: - (failed ? "one or more stores are degraded; " : "") + - unscoped + - " legacy unscoped record(s), never auto-injected", - }; - }, - }, - { - id: "mcp.registry", - category: "mcp", - run: () => { - const state = mcpStore.inspect(); - return { - status: - state.status === "corrupt" || state.status === "unreadable" - ? "fail" - : state.status === "missing" - ? "warn" - : "pass", - detail: "local MCP registry " + state.status, - }; - }, - }, - { - id: "mcp.broker", - category: "mcp", - run: async () => { - if (!deep) return { status: "skip", detail: "deep check not requested" }; - const report = await collectMcpDiagnostics(mcpClient, mcpStore, { - timeoutMs, - includeToolCounts: true, - now: dependencies.now, - }); - return { - status: report.brokerStatus === "available" ? "pass" : "warn", - detail: - report.brokerStatus === "available" - ? report.providers.length + " provider(s) diagnosed" - : "broker unavailable", - }; - }, - }, - { - id: "persistence.local", - category: "persistence", - run: () => { - try { - accessSync(nearestExisting(configDir()), constants.R_OK | constants.W_OK); - return { status: "pass", detail: "local persistence root is readable and writable" }; - } catch { - return { status: "fail", detail: "local persistence root is unavailable" }; - } - }, - }, - ]; - - const checks = await executeDiagnosticChecks(specs); - const summary: Record = { pass: 0, warn: 0, fail: 0, skip: 0 }; - for (const check of checks) summary[check.status] += 1; - return { - schemaVersion: 1, - generatedAt: dependencies.now ?? new Date().toISOString(), - deep, - checks, - summary, - }; -} - -export function renderDiagnosticReport(report: DiagnosticReport): string { - const lines = [ - "Aether doctor v" + report.schemaVersion + (report.deep ? " (deep)" : ""), - ]; - for (const check of report.checks) { - lines.push(" [" + check.status + "] " + check.id + ": " + check.detail); - } - lines.push( - "Summary: " + - report.summary.pass + - " pass, " + - report.summary.warn + - " warn, " + - report.summary.fail + - " fail, " + - report.summary.skip + - " skip", - ); - return lines.join("\n") + "\n"; -} +// Thin re-export shim — the doctor engine now lives in src/core/diagnostics/. +// Existing imports of "./diagnostics.js" keep working unchanged. + +export { + summarize, + toV1Report, + type DiagnosticCheck, + type DiagnosticReport, + type DiagnosticStatus, + type DoctorCheckV2, + type DoctorMode, + type DoctorReportV2, + type DoctorSeverity, +} from "./diagnostics/contracts.js"; +export { + DIAGNOSTIC_CONCURRENCY, + clampDiagnosticTimeout, + executeDiagnosticChecks, + type CheckOutcome, + type DiagnosticCheckSpec, + type ExecutedCheck, +} from "./diagnostics/executor.js"; +export { + diagnosticReport, + doctorReportV2, + registerChecks, + REGISTRY, + type CapabilityProbeResult, + type CheckDeps, + type CheckMode, + type CheckSpec, + type DiagnosticDependencies, + type DoctorRunOptions, +} from "./diagnostics/registry.js"; +export { renderDiagnosticReport, renderDoctorJUnit, renderDoctorReport } from "./diagnostics/render.js"; diff --git a/src/core/diagnostics/auth.ts b/src/core/diagnostics/auth.ts new file mode 100644 index 0000000..f84b8e7 --- /dev/null +++ b/src/core/diagnostics/auth.ts @@ -0,0 +1,20 @@ +import type { CheckSpec } from "./registry.js"; + +export function authChecks(): CheckSpec[] { + return [ + { + id: "authentication", + category: "auth", + mode: "fast", + severity: "warning", + run: async (deps) => { + const configured = Boolean(await deps.ctx.tokens.get()); + return { + status: configured ? ("pass" as const) : ("warn" as const), + detail: configured ? "credential configured" : "signed out — next: aether auth login", + configured, + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/backend.ts b/src/core/diagnostics/backend.ts new file mode 100644 index 0000000..d4ca28c --- /dev/null +++ b/src/core/diagnostics/backend.ts @@ -0,0 +1,44 @@ +import { MODELS_PATH } from "../transport.js"; +import { bounded } from "../mcp_diagnostics.js"; +import type { CheckSpec } from "./registry.js"; + +export function backendChecks(): CheckSpec[] { + return [ + { + id: "backend.catalog", + category: "backend", + mode: "network", + severity: "warning", + run: async (deps) => { + try { + await bounded(deps.ctx.api.getJson(MODELS_PATH), deps.timeoutMs); + return { status: "pass" as const, detail: "bounded catalog request succeeded", reachable: true }; + } catch { + return { status: "warn" as const, detail: "backend catalog unavailable — next: check network or aether auth status" }; + } + }, + }, + { + id: "backend.capabilities", + category: "backend", + mode: "network", + severity: "info", + run: async (deps) => { + if (!deps.apiProbe) { + return { status: "skip" as const, detail: "no capability probe configured" }; + } + try { + const contract = await bounded(deps.apiProbe(), deps.timeoutMs); + deps.capability.value = { version: contract.version, digest: contract.digest }; + return { + status: "pass" as const, + detail: "capability contract v" + contract.version, + reachable: true, + }; + } catch { + return { status: "warn" as const, detail: "capability manifest unavailable" }; + } + }, + }, + ]; +} diff --git a/src/core/diagnostics/contracts.ts b/src/core/diagnostics/contracts.ts new file mode 100644 index 0000000..eac76a8 --- /dev/null +++ b/src/core/diagnostics/contracts.ts @@ -0,0 +1,72 @@ +// Doctor report contracts. v2 is the native shape (snake_case wire fields); +// v1 stays exported and derivable so `aether doctor --json` consumers and the +// original tests keep working unchanged. + +export type DiagnosticStatus = "pass" | "warn" | "fail" | "skip"; +export type DoctorMode = "fast" | "network" | "live" | "fix"; +export type DoctorSeverity = "info" | "warning" | "critical"; + +export interface DoctorCheckV2 { + id: string; + category: string; + status: DiagnosticStatus; + severity: DoctorSeverity; + configured: boolean; + reachable: boolean; + verified: boolean; + detail: string; + /** Structural promise: check details never carry file contents or secrets. */ + evidence: { metadata_only: true }; + repair_id: string | null; + duration_ms: number; +} + +export interface DoctorReportV2 { + schema_version: 2; + mode: DoctorMode; + generated_at: string; + capability_contract: { version: number; digest: string } | null; + checks: DoctorCheckV2[]; + summary: Record; +} + +// ── v1 compatibility ───────────────────────────────────────────────────────── + +export interface DiagnosticCheck { + id: string; + category: string; + status: DiagnosticStatus; + detail: string; + durationMs: number; +} + +export interface DiagnosticReport { + schemaVersion: 1; + generatedAt: string; + deep: boolean; + checks: DiagnosticCheck[]; + summary: Record; +} + +export function summarize(statuses: readonly DiagnosticStatus[]): Record { + const summary: Record = { pass: 0, warn: 0, fail: 0, skip: 0 }; + for (const status of statuses) summary[status] += 1; + return summary; +} + +/** Downgrade a v2 report to the v1 wire shape — the `--json` default. */ +export function toV1Report(report: DoctorReportV2): DiagnosticReport { + return { + schemaVersion: 1, + generatedAt: report.generated_at, + deep: report.mode !== "fast", + checks: report.checks.map((check) => ({ + id: check.id, + category: check.category, + status: check.status, + detail: check.detail, + durationMs: check.duration_ms, + })), + summary: { ...report.summary }, + }; +} diff --git a/src/core/diagnostics/dev_session_live.ts b/src/core/diagnostics/dev_session_live.ts new file mode 100644 index 0000000..07bfeea --- /dev/null +++ b/src/core/diagnostics/dev_session_live.ts @@ -0,0 +1,154 @@ +// Doctor --live: drive one SYNTHETIC dev session end-to-end and report what +// was actually proven. The server side is scripted (AETHER-CLOUD +// lib/agent_dev/synthetic.py) — no model call, zero UVT by construction. +// +// Proven steps: authentication, session create (capability negotiation), +// sequence-numbered frame receipt, pause+resume acknowledgement, a sandboxed +// write→read tool round trip executed by THIS host, tool-result +// acknowledgement, session close, and no local residue. + +import { mkdirSync, existsSync, rmSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { ApiClient } from "../transport.js"; +import { + DEV_SESSIONS_PATH, + devSessionStreamPath, + devSessionToolResultsPath, + devSessionControlPath, + devSessionPath, +} from "../transport.js"; +import { decodeSse } from "../stream.js"; +import { ToolExecutor } from "../tool_executor.js"; +import { DEV_PROTOCOL_VERSION } from "../brain_cloud.js"; + +export interface LiveProbeStep { + id: string; + ok: boolean; + detail: string; +} + +export interface LiveProbeReport { + ok: boolean; + steps: readonly LiveProbeStep[]; +} + +interface CreatedSession { + session_id: string; + protocol_version: number; + synthetic?: boolean; +} + +/** + * Run the synthetic live probe. The sandbox is a private temp directory — + * never the user's project — and is verified empty-then-removed at the end. + */ +export async function runLiveProbe(api: ApiClient): Promise { + const steps: LiveProbeStep[] = []; + const step = (id: string, ok: boolean, detail: string): void => { + steps.push({ id, ok, detail }); + }; + + const sandbox = join(tmpdir(), "aether-doctor-live-" + process.pid); + mkdirSync(join(sandbox, ".aether-doctor"), { recursive: true }); + const exec = new ToolExecutor(sandbox); + + let sessionId: string | null = null; + try { + let created: CreatedSession; + try { + created = await api.postJson(DEV_SESSIONS_PATH, { + task: "doctor live probe", + surface: "aether_agent", + model: null, + effort: null, + capabilities: ["read_file", "write_file"], + protocol_version: DEV_PROTOCOL_VERSION, + synthetic: true, + }); + } catch (err) { + step("live.create", false, err instanceof Error ? err.message : String(err)); + return { ok: false, steps }; + } + sessionId = created.session_id; + step("live.authenticate", true, "session created as the signed-in account"); + step( + "live.negotiate", + created.protocol_version === DEV_PROTOCOL_VERSION, + "protocol v" + created.protocol_version + (created.synthetic ? " · synthetic" : ""), + ); + + // Pause + resume acknowledgement (synchronous HTTP acks). + const paused = await api.postJson<{ ok?: boolean; state?: string }>( + devSessionControlPath(sessionId), { action: "pause", note: null }, + ); + step("live.pause", paused.state === "paused", "state " + String(paused.state)); + const resumed = await api.postJson<{ ok?: boolean; state?: string }>( + devSessionControlPath(sessionId), { action: "resume", note: null }, + ); + step("live.resume", resumed.state === "running" || resumed.state === "created", "state " + String(resumed.state)); + + // Stream: execute the scripted tool calls locally inside the sandbox. + let lastSeq = 0; + let sequenceOk = true; + let sawDone = false; + let doneOk = false; + let toolRoundTrips = 0; + const stream = await api.stream(devSessionStreamPath(sessionId, 0), undefined, { method: "GET" }); + for await (const frame of decodeSse(stream)) { + if (typeof frame.seq === "number") { + if (frame.seq <= lastSeq) { sequenceOk = false; continue; } + lastSeq = frame.seq; + } + if (frame.type === "tool_call") { + const result = await exec.executeAsync(frame.name, frame.args); + await api.postJson(devSessionToolResultsPath(sessionId), { + tool_call_id: frame.toolCallId, + status: result.exitCode === 0 ? "ok" : "error", + exit_code: result.exitCode, + output: result.output, + truncated: false, + }); + toolRoundTrips += 1; + } + if (frame.type === "error") { + step("live.session", false, frame.msg); + break; + } + if (frame.type === "done") { + sawDone = true; + doneOk = frame.ok !== false; + break; + } + } + step("live.sequence", sequenceOk && lastSeq > 0, "frames 1.." + lastSeq + " strictly increasing"); + step("live.tool_round_trip", toolRoundTrips >= 2 && doneOk, toolRoundTrips + " sandboxed tool round trips, server verified the echo"); + step("live.done", sawDone && doneOk, sawDone ? "terminal done ok=" + doneOk : "stream ended without done"); + + // Teardown. + await api.deleteJson(devSessionPath(sessionId)); + sessionId = null; + step("live.close", true, "session deleted"); + } catch (err) { + step("live.error", false, err instanceof Error ? err.message : String(err)); + } finally { + if (sessionId) { + try { await api.deleteJson(devSessionPath(sessionId)); } catch { /* best effort */ } + } + // No residue: the probe file must be the ONLY thing in the sandbox, and + // removing the sandbox must leave nothing behind. + try { + const leftovers = existsSync(sandbox) ? readdirSync(join(sandbox, ".aether-doctor")) : []; + rmSync(sandbox, { recursive: true, force: true }); + steps.push({ + id: "live.no_residue", + ok: !existsSync(sandbox), + detail: leftovers.length + " probe file(s) removed with the sandbox", + }); + } catch { + steps.push({ id: "live.no_residue", ok: false, detail: "sandbox cleanup failed: " + sandbox }); + } + } + + return { ok: steps.every((s) => s.ok), steps }; +} diff --git a/src/core/diagnostics/executor.ts b/src/core/diagnostics/executor.ts new file mode 100644 index 0000000..9b837fd --- /dev/null +++ b/src/core/diagnostics/executor.ts @@ -0,0 +1,79 @@ +// Bounded worker pool for diagnostic checks. Results keep declaration order +// regardless of completion order; every check is individually fail-soft. + +import type { DiagnosticCheck, DiagnosticStatus } from "./contracts.js"; + +export const DIAGNOSTIC_CONCURRENCY = 8; + +export interface CheckOutcome { + status: DiagnosticStatus; + detail: string; + configured?: boolean; + reachable?: boolean; + verified?: boolean; +} + +export interface DiagnosticCheckSpec { + id: string; + category: string; + run(): Promise | CheckOutcome; +} + +export interface ExecutedCheck extends DiagnosticCheck { + configured: boolean; + reachable: boolean; + verified: boolean; +} + +/** Per-check network/API budget: 100ms..10s, default 2s. */ +export function clampDiagnosticTimeout(timeoutMs?: number): number { + return Math.max(100, Math.min(timeoutMs ?? 2000, 10000)); +} + +function monotonicMs(): number { + return Number(process.hrtime.bigint()) / 1_000_000; +} + +export async function executeDiagnosticChecks( + specs: DiagnosticCheckSpec[], + concurrency = DIAGNOSTIC_CONCURRENCY, +): Promise { + const width = Math.max(1, Math.min(Math.floor(concurrency), 32)); + const results: ExecutedCheck[] = new Array(specs.length); + let cursor = 0; + const worker = async (): Promise => { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= specs.length) return; + const spec = specs[index]!; + const started = monotonicMs(); + try { + const result = await spec.run(); + results[index] = { + id: spec.id, + category: spec.category, + status: result.status, + detail: result.detail, + configured: result.configured ?? result.status !== "skip", + reachable: result.reachable ?? false, + verified: result.verified ?? result.status === "pass", + durationMs: Math.max(0, monotonicMs() - started), + }; + } catch { + results[index] = { + id: spec.id, + category: spec.category, + status: "fail", + detail: "check failed safely", + configured: false, + reachable: false, + verified: false, + durationMs: Math.max(0, monotonicMs() - started), + }; + } + } + }; + await Promise.all(Array.from({ length: Math.min(width, specs.length) }, () => worker())); + return results; +} diff --git a/src/core/diagnostics/instructions.ts b/src/core/diagnostics/instructions.ts new file mode 100644 index 0000000..a9cd0da --- /dev/null +++ b/src/core/diagnostics/instructions.ts @@ -0,0 +1,52 @@ +// Instruction-graph checks — fast-mode, filesystem-only, metadata-only. + +import type { CheckSpec } from "./registry.js"; + +export function instructionsChecks(): CheckSpec[] { + return [ + { + id: "instructions.graph", + category: "instructions", + mode: "fast", + severity: "warning", + run: (deps) => { + const graph = deps.instructionGraph(); + const parseWarnings = + graph.sources.reduce( + (sum, source) => sum + source.warnings.length + (source.parseStatus === "ok" ? 0 : 1), + 0, + ) + graph.skipped.length; + return { + status: parseWarnings ? ("warn" as const) : ("pass" as const), + detail: + graph.sources.length + + " instruction source(s), " + + parseWarnings + + " parse warning(s)" + + (parseWarnings ? " — next: review flagged instruction files" : ""), + }; + }, + }, + { + id: "instructions.conflicts", + category: "instructions", + mode: "fast", + severity: "warning", + run: (deps) => { + const graph = deps.instructionGraph(); + if (graph.conflicts.length === 0) { + return { status: "pass" as const, detail: "no instruction conflicts detected" }; + } + const topics = graph.conflicts.map((conflict) => conflict.topic).join(", "); + return { + status: "warn" as const, + detail: + graph.conflicts.length + + " conflict(s): " + + topics + + " — next: align the higher-precedence source", + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/mcp.ts b/src/core/diagnostics/mcp.ts new file mode 100644 index 0000000..b7cd64b --- /dev/null +++ b/src/core/diagnostics/mcp.ts @@ -0,0 +1,48 @@ +import { collectMcpDiagnostics } from "../mcp_diagnostics.js"; +import type { CheckSpec } from "./registry.js"; + +export function mcpChecks(): CheckSpec[] { + return [ + { + id: "mcp.registry", + category: "mcp", + mode: "fast", + severity: "warning", + run: (deps) => { + const state = deps.mcpStore.inspect(); + const status = + state.status === "corrupt" || state.status === "unreadable" + ? ("fail" as const) + : state.status === "missing" + ? ("warn" as const) + : ("pass" as const); + return { + status, + detail: + "local MCP registry " + + state.status + + (status === "fail" ? " — next: aether mcp repair" : ""), + }; + }, + }, + { + id: "mcp.broker", + category: "mcp", + mode: "network", + severity: "warning", + run: async (deps) => { + const report = await collectMcpDiagnostics(deps.mcpClient, deps.mcpStore, { + timeoutMs: deps.timeoutMs, + includeToolCounts: true, + ...(deps.now != null ? { now: deps.now } : {}), + }); + const available = report.brokerStatus === "available"; + return { + status: available ? ("pass" as const) : ("warn" as const), + detail: available ? report.providers.length + " provider(s) diagnosed" : "broker unavailable", + reachable: available, + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/memory.ts b/src/core/diagnostics/memory.ts new file mode 100644 index 0000000..0739dce --- /dev/null +++ b/src/core/diagnostics/memory.ts @@ -0,0 +1,26 @@ +import { localMemoryReport } from "../memory.js"; +import type { CheckSpec } from "./registry.js"; + +export function memoryChecks(): CheckSpec[] { + return [ + { + id: "memory.health", + category: "memory", + mode: "fast", + severity: "warning", + run: (deps) => { + const report = localMemoryReport(deps.ctx.flags.cwd, undefined, deps.memoryRoots, deps.now); + const sources = report.tiers.flatMap((tier) => tier.sources); + const failed = sources.some((source) => source.status === "degraded"); + const unscoped = sources.reduce((sum, source) => sum + source.unscoped, 0); + return { + status: failed ? ("warn" as const) : ("pass" as const), + detail: + (failed ? "one or more stores are degraded; " : "") + + unscoped + + " legacy unscoped record(s), never auto-injected", + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/persistence.ts b/src/core/diagnostics/persistence.ts new file mode 100644 index 0000000..b52bf28 --- /dev/null +++ b/src/core/diagnostics/persistence.ts @@ -0,0 +1,30 @@ +import { accessSync, constants, existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { configDir } from "../config.js"; +import type { CheckSpec } from "./registry.js"; + +function nearestExisting(path: string): string { + let current = resolve(path); + while (!existsSync(current) && dirname(current) !== current) current = dirname(current); + return current; +} + +export function persistenceChecks(): CheckSpec[] { + return [ + { + id: "persistence.local", + category: "persistence", + mode: "fast", + severity: "critical", + repairId: "repair.config_dir", + run: () => { + try { + accessSync(nearestExisting(configDir()), constants.R_OK | constants.W_OK); + return { status: "pass" as const, detail: "local persistence root is readable and writable" }; + } catch { + return { status: "fail" as const, detail: "local persistence root is unavailable — next: aether doctor --fix" }; + } + }, + }, + ]; +} diff --git a/src/core/diagnostics/registry.ts b/src/core/diagnostics/registry.ts new file mode 100644 index 0000000..8fccdbe --- /dev/null +++ b/src/core/diagnostics/registry.ts @@ -0,0 +1,185 @@ +// Doctor check registry and report runner. +// +// Fast contract: mode "fast" (and "fix") performs NO network calls, NO +// mutations, NO model calls — checks above the mode rank are emitted as skip +// without running. "network" (alias --deep) adds bounded backend probes. + +import type { AppContext } from "../context.js"; +import type { MemoryRoots } from "../memory.js"; +import type { McpClient } from "../mcp.js"; +import { LocalMcpStore } from "../mcp_store.js"; +import { McpClient as McpClientImpl } from "../mcp.js"; +import { discoverSkills } from "../skills/skill_discovery.js"; +import type { SkillIndex } from "../skills/skill_types.js"; +import { resolveInstructionGraph } from "../instructions/instruction_resolver.js"; +import type { InstructionGraph } from "../instructions/instruction_types.js"; +import { + summarize, + toV1Report, + type DiagnosticReport, + type DoctorCheckV2, + type DoctorMode, + type DoctorReportV2, + type DoctorSeverity, +} from "./contracts.js"; +import { clampDiagnosticTimeout, executeDiagnosticChecks, type CheckOutcome } from "./executor.js"; +import { runtimeChecks } from "./runtime.js"; +import { workspaceChecks } from "./workspace.js"; +import { transportChecks } from "./transport.js"; +import { authChecks } from "./auth.js"; +import { backendChecks } from "./backend.js"; +import { toolsChecks } from "./tools.js"; +import { memoryChecks } from "./memory.js"; +import { mcpChecks } from "./mcp.js"; +import { persistenceChecks } from "./persistence.js"; +import { skillsChecks } from "./skills.js"; +import { instructionsChecks } from "./instructions.js"; + +export interface CapabilityProbeResult { + version: number; + digest: string; +} + +export interface DiagnosticDependencies { + now?: string; + mcpClient?: McpClient; + mcpStore?: LocalMcpStore; + memoryRoots?: MemoryRoots; + timeoutMs?: number; + /** Injected capability-manifest probe (GET /agent/capabilities); absent → skip. */ + apiProbe?: () => Promise; +} + +export interface CheckDeps { + ctx: AppContext; + mode: DoctorMode; + timeoutMs: number; + mcpStore: LocalMcpStore; + mcpClient: McpClient; + memoryRoots?: MemoryRoots; + now?: string; + apiProbe?: () => Promise; + /** Memoized per run so several checks share one filesystem scan. */ + skillIndex(): SkillIndex; + instructionGraph(): InstructionGraph; + capability: { value: CapabilityProbeResult | null }; +} + +export type CheckMode = "fast" | "network" | "live"; + +export interface CheckSpec { + id: string; + category: string; + /** Minimum report mode that actually runs the check. */ + mode: CheckMode; + severity: DoctorSeverity; + /** Repair action offered when the check does not pass. */ + repairId?: string; + run(deps: CheckDeps): Promise | CheckOutcome; +} + +export function registerChecks(): CheckSpec[] { + return [ + ...runtimeChecks(), + ...workspaceChecks(), + ...transportChecks(), + ...authChecks(), + ...backendChecks(), + ...toolsChecks(), + ...memoryChecks(), + ...mcpChecks(), + ...persistenceChecks(), + ...skillsChecks(), + ...instructionsChecks(), + ]; +} + +export const REGISTRY: readonly CheckSpec[] = registerChecks(); + +const MODE_RANK: Record = { fast: 0, network: 1, live: 2 }; + +function reportRank(mode: DoctorMode): number { + // "fix" plans repairs over the fast baseline: same zero-network contract. + return mode === "fix" ? MODE_RANK.fast : MODE_RANK[mode]; +} + +export interface DoctorRunOptions { + mode: DoctorMode; + /** Category filter (--category a,b); empty/absent runs everything. */ + categories?: readonly string[]; +} + +function memoize(compute: () => T): () => T { + let cached: { value: T } | null = null; + return () => (cached ??= { value: compute() }).value; +} + +export async function doctorReportV2( + ctx: AppContext, + options: DoctorRunOptions, + dependencies: DiagnosticDependencies = {}, +): Promise { + const deps: CheckDeps = { + ctx, + mode: options.mode, + timeoutMs: clampDiagnosticTimeout(dependencies.timeoutMs), + mcpStore: dependencies.mcpStore ?? new LocalMcpStore(), + mcpClient: dependencies.mcpClient ?? new McpClientImpl(ctx.api), + ...(dependencies.memoryRoots != null ? { memoryRoots: dependencies.memoryRoots } : {}), + ...(dependencies.now != null ? { now: dependencies.now } : {}), + ...(dependencies.apiProbe != null ? { apiProbe: dependencies.apiProbe } : {}), + skillIndex: memoize(() => discoverSkills({ projectRoot: ctx.flags.cwd })), + instructionGraph: memoize(() => resolveInstructionGraph(ctx.flags.cwd)), + capability: { value: null }, + }; + + const wanted = options.categories?.length + ? REGISTRY.filter((spec) => options.categories!.includes(spec.category)) + : [...REGISTRY]; + const rank = reportRank(options.mode); + const executed = await executeDiagnosticChecks( + wanted.map((spec) => ({ + id: spec.id, + category: spec.category, + run: () => + MODE_RANK[spec.mode] > rank + ? { status: "skip" as const, detail: "requires --network", configured: false, verified: false } + : spec.run(deps), + })), + ); + + const checks: DoctorCheckV2[] = executed.map((check, index) => { + const spec = wanted[index]!; + return { + id: check.id, + category: check.category, + status: check.status, + severity: spec.severity, + configured: check.configured, + reachable: check.reachable, + verified: check.verified, + detail: check.detail, + evidence: { metadata_only: true }, + repair_id: spec.repairId != null && check.status !== "pass" && check.status !== "skip" ? spec.repairId : null, + duration_ms: check.durationMs, + }; + }); + + return { + schema_version: 2, + mode: options.mode, + generated_at: deps.now ?? new Date().toISOString(), + capability_contract: deps.capability.value, + checks, + summary: summarize(checks.map((check) => check.status)), + }; +} + +/** v1 entrypoint — unchanged signature and wire shape for existing consumers. */ +export async function diagnosticReport( + ctx: AppContext, + deep: boolean, + dependencies: DiagnosticDependencies = {}, +): Promise { + return toV1Report(await doctorReportV2(ctx, { mode: deep ? "network" : "fast" }, dependencies)); +} diff --git a/src/core/diagnostics/render.ts b/src/core/diagnostics/render.ts new file mode 100644 index 0000000..662759b --- /dev/null +++ b/src/core/diagnostics/render.ts @@ -0,0 +1,103 @@ +// Human and CI renderers for doctor reports. v1 rendering is preserved +// verbatim for compatibility; v2 groups by category with state words. + +import type { DiagnosticReport, DoctorCheckV2, DoctorReportV2 } from "./contracts.js"; + +const STATUS_MARK: Record = { + pass: "✓", + warn: "!", + fail: "✗", + skip: "—", +}; + +function stateWords(check: DoctorCheckV2): string { + if (check.status === "skip") return "skipped"; + const parts = [check.configured ? "configured" : "not configured"]; + if (check.reachable) parts.push("reachable"); + if (check.verified) parts.push("verified now"); + return parts.join(" · "); +} + +export function renderDoctorReport(report: DoctorReportV2): string { + const lines = ["Aether doctor v" + report.schema_version + " (" + report.mode + ")"]; + if (report.capability_contract) { + lines.push( + "capability contract v" + + report.capability_contract.version + + " · digest " + + report.capability_contract.digest.slice(0, 12), + ); + } + let category = ""; + for (const check of report.checks) { + if (check.category !== category) { + category = check.category; + lines.push("", category.toUpperCase()); + } + lines.push( + " " + STATUS_MARK[check.status] + " " + check.id + " — " + check.detail + " (" + stateWords(check) + ")", + ); + } + lines.push( + "", + "Summary: " + + report.summary.pass + " pass · " + + report.summary.warn + " warn · " + + report.summary.fail + " fail · " + + report.summary.skip + " skip", + ); + return lines.join("\n") + "\n"; +} + +/** v1 renderer — kept byte-identical to the original flat format. */ +export function renderDiagnosticReport(report: DiagnosticReport): string { + const lines = [ + "Aether doctor v" + report.schemaVersion + (report.deep ? " (deep)" : ""), + ]; + for (const check of report.checks) { + lines.push(" [" + check.status + "] " + check.id + ": " + check.detail); + } + lines.push( + "Summary: " + + report.summary.pass + + " pass, " + + report.summary.warn + + " warn, " + + report.summary.fail + + " fail, " + + report.summary.skip + + " skip", + ); + return lines.join("\n") + "\n"; +} + +/** JUnit XML — mirrors skill_eval.ts renderEvalJUnit; suites are categories. */ +export function renderDoctorJUnit(report: DoctorReportV2): string { + const escape = (text: string): string => + text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const categories = [...new Set(report.checks.map((check) => check.category))]; + const failures = report.summary.fail; + const lines = ['']; + lines.push(''); + for (const category of categories) { + const checks = report.checks.filter((check) => check.category === category); + const failed = checks.filter((check) => check.status === "fail").length; + lines.push(' '); + for (const check of checks) { + if (check.status === "fail") { + lines.push(' '); + lines.push(' '); + lines.push(" "); + } else if (check.status === "skip") { + lines.push(' '); + lines.push(" "); + lines.push(" "); + } else { + lines.push(' '); + } + } + lines.push(" "); + } + lines.push(""); + return lines.join("\n") + "\n"; +} diff --git a/src/core/diagnostics/repair.ts b/src/core/diagnostics/repair.ts new file mode 100644 index 0000000..d712dc8 --- /dev/null +++ b/src/core/diagnostics/repair.ts @@ -0,0 +1,267 @@ +// Safe repair — narrow, backup-first, receipted. +// +// Transaction per target: inspect → plan → back up → mutate atomically → +// verify → rollback on failure → append a metadata-only receipt line. +// +// Forbidden classes (never implement here): credential changes, git +// mutations, source edits, dependency installs. + +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { configDir } from "../config.js"; +import { skillSettingsPath, SKILL_SETTINGS_SCHEMA_VERSION } from "../skills/skill_settings.js"; +import { trustStorePath, SKILL_TRUST_SCHEMA_VERSION } from "../skills/skill_trust.js"; + +export interface RepairPlan { + repairId: string; + targetClass: string; + target: string; + backupPath: string | null; + detail: string; +} + +export interface RepairAction { + id: string; + targetClass: string; + describe(): string; + /** Inspect and return one plan per applicable target; empty = nothing to do. */ + plan(now: string): RepairPlan[]; + apply(plan: RepairPlan): void; + verify(plan: RepairPlan): boolean; + rollback(plan: RepairPlan): void; +} + +export interface RepairOutcome { + plan: RepairPlan; + applied: boolean; + verified: boolean; + rolledBack: boolean; +} + +const STALE_TMP_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +function fileDigest(path: string): string | null { + try { + return createHash("sha256").update(readFileSync(path)).digest("hex"); + } catch { + return null; + } +} + +function atomicWriteJson(path: string, body: unknown): void { + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +/** A store file that exists but no longer parses to its schema shape. */ +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; + } +} + +interface SkillStoreTarget { + path(): string; + listKey: string; + empty(): Record; +} + +const SKILL_STORES: SkillStoreTarget[] = [ + { + path: skillSettingsPath, + listKey: "settings", + empty: () => ({ schema_version: SKILL_SETTINGS_SCHEMA_VERSION, settings: [] }), + }, + { + path: trustStorePath, + listKey: "records", + empty: () => ({ schema_version: SKILL_TRUST_SCHEMA_VERSION, records: [] }), + }, +]; + +function skillStoreFor(target: string): SkillStoreTarget | undefined { + return SKILL_STORES.find((store) => store.path() === target); +} + +const repairSkillIndex: RepairAction = { + id: "repair.skill_index", + targetClass: "skill-index-store", + describe: () => "rebuild corrupt skill settings/trust store files (backup-first)", + plan: () => + SKILL_STORES.filter((store) => storeCorrupt(store.path(), store.listKey)).map((store) => ({ + repairId: "repair.skill_index", + targetClass: "skill-index-store", + target: store.path(), + backupPath: store.path() + ".corrupt-" + process.pid, + detail: "rebuild corrupt store as empty after backing up", + })), + apply: (plan) => { + const store = skillStoreFor(plan.target); + if (!store) throw new Error("unknown skill store target"); + atomicWriteJson(plan.target, store.empty()); + }, + verify: (plan) => { + const store = skillStoreFor(plan.target); + return store != null && !storeCorrupt(plan.target, store.listKey); + }, + rollback: (plan) => { + if (plan.backupPath && existsSync(plan.backupPath)) copyFileSync(plan.backupPath, plan.target); + }, +}; + +const repairConfigDir: RepairAction = { + id: "repair.config_dir", + targetClass: "config-dir", + describe: () => "create the missing local config directory (owner-only)", + plan: () => + existsSync(configDir()) + ? [] + : [ + { + repairId: "repair.config_dir", + targetClass: "config-dir", + target: configDir(), + backupPath: null, + detail: "create directory with mode 0700", + }, + ], + apply: (plan) => { + mkdirSync(plan.target, { recursive: true, mode: 0o700 }); + }, + verify: (plan) => existsSync(plan.target) && statSync(plan.target).isDirectory(), + rollback: () => { + // Creating an empty directory is harmless; removing it on a failed verify + // could race another writer — leave it in place. + }, +}; + +const repairStaleTmp: RepairAction = { + id: "repair.stale_tmp", + targetClass: "temp-files", + describe: () => "remove *.tmp files in the config directory older than one day", + plan: (now) => { + const dir = configDir(); + if (!existsSync(dir)) return []; + const cutoff = Date.parse(now) - STALE_TMP_MAX_AGE_MS; + const plans: RepairPlan[] = []; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return []; + } + for (const entry of entries.sort()) { + if (!entry.endsWith(".tmp")) continue; + const full = join(dir, entry); + try { + const stat = statSync(full); + if (stat.isFile() && stat.mtimeMs < cutoff) { + plans.push({ + repairId: "repair.stale_tmp", + targetClass: "temp-files", + target: full, + backupPath: null, + detail: "remove stale temp file", + }); + } + } catch { + // unreadable entry — never a repair candidate + } + } + return plans; + }, + apply: (plan) => { + rmSync(plan.target, { force: true }); + }, + verify: (plan) => !existsSync(plan.target), + rollback: () => { + // A deletion that failed verify left the file in place — nothing to undo. + }, +}; + +export const REPAIR_ACTIONS: readonly RepairAction[] = [repairSkillIndex, repairConfigDir, repairStaleTmp]; + +export function repairReceiptsPath(): string { + return join(configDir(), "repair-receipts.jsonl"); +} + +export function planRepairs(now: string, actions: readonly RepairAction[] = REPAIR_ACTIONS): RepairPlan[] { + const plans: RepairPlan[] = []; + for (const action of actions) { + try { + plans.push(...action.plan(now)); + } catch { + // Inspection must never break doctor — an unplannable action is skipped. + } + } + return plans; +} + +function appendReceipt(receipt: Record): void { + mkdirSync(configDir(), { recursive: true, mode: 0o700 }); + appendFileSync(repairReceiptsPath(), JSON.stringify(receipt) + "\n", "utf8"); +} + +export function executeRepairs( + plans: readonly RepairPlan[], + now: string, + actions: readonly RepairAction[] = REPAIR_ACTIONS, +): RepairOutcome[] { + const outcomes: RepairOutcome[] = []; + for (const plan of plans) { + const action = actions.find((candidate) => candidate.id === plan.repairId); + if (!action) { + outcomes.push({ plan, applied: false, verified: false, rolledBack: false }); + continue; + } + const beforeDigest = fileDigest(plan.target); + let applied = false; + let verified = false; + let rolledBack = false; + try { + if (plan.backupPath && existsSync(plan.target)) copyFileSync(plan.target, plan.backupPath); + action.apply(plan); + applied = true; + verified = action.verify(plan); + } catch { + verified = false; + } + if (applied && !verified) { + try { + action.rollback(plan); + rolledBack = true; + } catch { + rolledBack = false; + } + } + // Receipt is metadata-only: digests and paths, never file contents. + appendReceipt({ + repair_id: plan.repairId, + target_class: plan.targetClass, + before_digest: beforeDigest, + after_digest: fileDigest(plan.target), + backup_path: plan.backupPath, + verified, + ts: now, + }); + outcomes.push({ plan, applied, verified, rolledBack }); + } + return outcomes; +} diff --git a/src/core/diagnostics/runtime.ts b/src/core/diagnostics/runtime.ts new file mode 100644 index 0000000..712038e --- /dev/null +++ b/src/core/diagnostics/runtime.ts @@ -0,0 +1,25 @@ +import type { CheckSpec } from "./registry.js"; + +export function runtimeChecks(): CheckSpec[] { + return [ + { + id: "runtime.node", + category: "runtime", + mode: "fast", + severity: "critical", + run: () => { + // Must track package.json's engines.node — this project's own test + // script needs Node 24 (`--test --test-isolation` support). + const MIN_SUPPORTED_NODE_MAJOR = 24; + const major = Number(process.versions.node.split(".")[0]); + return { + status: major >= MIN_SUPPORTED_NODE_MAJOR ? ("pass" as const) : ("fail" as const), + detail: + major >= MIN_SUPPORTED_NODE_MAJOR + ? "Node runtime supported" + : `Node ${MIN_SUPPORTED_NODE_MAJOR} or newer required`, + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/skills.ts b/src/core/diagnostics/skills.ts new file mode 100644 index 0000000..6d12d10 --- /dev/null +++ b/src/core/diagnostics/skills.ts @@ -0,0 +1,124 @@ +// Skill health checks — fast-mode, filesystem-only, metadata-only. Details +// carry counts and ids, never file contents. + +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { compareLock, projectLockPath, readSkillLock } from "../skills/skill_lock.js"; +import { skillSettingsPath } from "../skills/skill_settings.js"; +import { trustStorePath } from "../skills/skill_trust.js"; +import type { CheckSpec } from "./registry.js"; + +/** True when a local skill store file 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; + } +} + +export function skillsChecks(): CheckSpec[] { + return [ + { + id: "skills.index", + category: "skills", + mode: "fast", + severity: "warning", + repairId: "repair.skill_index", + run: (deps) => { + const index = deps.skillIndex(); + const corruptStores = + Number(storeCorrupt(skillSettingsPath(), "settings")) + Number(storeCorrupt(trustStorePath(), "records")); + if (corruptStores > 0) { + return { + status: "warn" as const, + detail: corruptStores + " skill store file(s) corrupt — next: aether doctor --fix", + }; + } + return { + status: index.errors.length ? ("warn" as const) : ("pass" as const), + detail: + index.skills.length + + " skill(s) indexed, " + + index.errors.length + + " index error(s)" + + (index.errors.length ? " — next: aether skills check --all" : ""), + }; + }, + }, + { + id: "skills.lock", + category: "skills", + mode: "fast", + severity: "warning", + run: (deps) => { + const index = deps.skillIndex(); + const projectSkills = index.skills.filter((descriptor) => descriptor.scope === "project"); + const lock = readSkillLock(projectLockPath(resolve(deps.ctx.flags.cwd))); + if (!lock.ok) { + if (lock.missing && projectSkills.length === 0) { + return { status: "pass" as const, detail: "no project skills, no lock required" }; + } + return { + status: "warn" as const, + detail: + (lock.missing ? "lock file missing" : "lock file unreadable") + + " — next: aether skills lock", + }; + } + const drift = compareLock( + lock.lock, + new Map(projectSkills.map((descriptor) => [descriptor.id, descriptor.sha256])), + ); + const drifted = drift.unlocked.length + drift.changed.length + drift.missing.length; + if (drifted === 0) return { status: "pass" as const, detail: "lock matches discovered project skills" }; + return { + status: "warn" as const, + detail: + "lock drift: " + + drift.unlocked.length + " unlocked, " + + drift.changed.length + " changed, " + + drift.missing.length + " missing — next: aether skills lock", + }; + }, + }, + { + id: "skills.trust", + category: "skills", + mode: "fast", + severity: "warning", + run: (deps) => { + const index = deps.skillIndex(); + const untrusted = index.skills.filter( + (descriptor) => + descriptor.scope === "project" && (descriptor.trust === "untrusted" || descriptor.trust === "changed"), + ); + if (untrusted.length === 0) { + return { status: "pass" as const, detail: "no project skills awaiting trust review" }; + } + return { + status: "warn" as const, + detail: untrusted.length + " project skill(s) untrusted or changed — next: aether skills trust ", + }; + }, + }, + { + id: "skills.evals", + category: "skills", + mode: "fast", + severity: "info", + run: (deps) => { + const index = deps.skillIndex(); + const missing = index.skills.filter((descriptor) => !descriptor.manifest.health.evalManifest); + if (index.skills.length === 0) return { status: "pass" as const, detail: "no skills discovered" }; + if (missing.length === 0) return { status: "pass" as const, detail: "all skills declare eval manifests" }; + return { + status: "warn" as const, + detail: missing.length + " of " + index.skills.length + " skill(s) declare no eval manifest — next: add health.eval_manifest", + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/tools.ts b/src/core/diagnostics/tools.ts new file mode 100644 index 0000000..349ef73 --- /dev/null +++ b/src/core/diagnostics/tools.ts @@ -0,0 +1,41 @@ +import { gateActionFor } from "../autonomy.js"; +import { validateToolDefinitionCoverage } from "../tool_registry.js"; +import type { CheckSpec } from "./registry.js"; + +export function toolsChecks(): CheckSpec[] { + return [ + { + id: "tools.schemas", + category: "tools", + mode: "fast", + severity: "critical", + run: () => { + const errors = validateToolDefinitionCoverage(); + return { + status: errors.length ? ("fail" as const) : ("pass" as const), + detail: errors.length ? "tool schema coverage incomplete" : "all protocol tools have schemas", + }; + }, + }, + { + id: "tools.gates", + category: "tools", + mode: "fast", + severity: "critical", + run: () => { + const valid = + gateActionFor("write_file") === "write" && + gateActionFor("run_shell") === "shell" && + gateActionFor("run_tests") === "shell" && + gateActionFor("git_commit") === "shell" && + gateActionFor("web_search") === "network" && + gateActionFor("web_fetch") === "network" && + gateActionFor("read_file") === null; + return { + status: valid ? ("pass" as const) : ("fail" as const), + detail: valid ? "side-effect gates are fail-closed" : "tool gate mapping drifted", + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/transport.ts b/src/core/diagnostics/transport.ts new file mode 100644 index 0000000..4d27db4 --- /dev/null +++ b/src/core/diagnostics/transport.ts @@ -0,0 +1,20 @@ +import { isCredentialSafeUrl } from "../transport.js"; +import type { CheckSpec } from "./registry.js"; + +export function transportChecks(): CheckSpec[] { + return [ + { + id: "configuration.transport", + category: "configuration", + mode: "fast", + severity: "critical", + run: (deps) => { + const safe = isCredentialSafeUrl(deps.ctx.cfg.baseUrl); + return { + status: safe ? ("pass" as const) : ("fail" as const), + detail: safe ? "backend URL transport is credential-safe" : "backend URL transport is unsafe", + }; + }, + }, + ]; +} diff --git a/src/core/diagnostics/workspace.ts b/src/core/diagnostics/workspace.ts new file mode 100644 index 0000000..046fc24 --- /dev/null +++ b/src/core/diagnostics/workspace.ts @@ -0,0 +1,35 @@ +import { existsSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { CheckSpec } from "./registry.js"; + +export function workspaceChecks(): CheckSpec[] { + return [ + { + id: "workspace.directory", + category: "workspace", + mode: "fast", + severity: "critical", + run: (deps) => { + const target = resolve(deps.ctx.flags.cwd); + const valid = existsSync(target) && statSync(target).isDirectory(); + return { + status: valid ? ("pass" as const) : ("fail" as const), + detail: valid ? "workspace directory available" : "workspace directory unavailable", + }; + }, + }, + { + id: "workspace.git", + category: "workspace", + mode: "fast", + severity: "info", + run: (deps) => { + const isGit = existsSync(join(deps.ctx.flags.cwd, ".git")); + return { + status: isGit ? ("pass" as const) : ("warn" as const), + detail: isGit ? "git metadata detected" : "workspace is not a git checkout", + }; + }, + }, + ]; +} diff --git a/src/core/envelope.ts b/src/core/envelope.ts index 01e22df..3db5095 100644 --- a/src/core/envelope.ts +++ b/src/core/envelope.ts @@ -65,6 +65,11 @@ export interface DevSessionWireRequest { max_uvt?: number; repo?: Record; protocol_version: number; + /** Additive Skills & Health context (contract v1). Absent = legacy request, + * byte-identical to pre-skills clients — old servers never see the keys. */ + capability_contract_version?: number; + skill_context?: Record; + instruction_context?: Record; } export interface BuildDevSessionArgs { @@ -75,6 +80,11 @@ export interface BuildDevSessionArgs { maxUvt?: number; repo?: Record; protocolVersion: number; + /** Typed skill context packet (context_packet.ts) serialized for the wire. */ + skillContext?: Record; + /** Typed instruction context packet (instruction_resolver.ts). */ + instructionContext?: Record; + capabilityContractVersion?: number; } export function buildDevSessionRequest(args: BuildDevSessionArgs): DevSessionWireRequest { @@ -88,6 +98,11 @@ export function buildDevSessionRequest(args: BuildDevSessionArgs): DevSessionWir }; if (args.maxUvt && args.maxUvt > 0) req.max_uvt = args.maxUvt; if (args.repo) req.repo = args.repo; + if (args.skillContext || args.instructionContext) { + req.capability_contract_version = args.capabilityContractVersion ?? 1; + if (args.skillContext) req.skill_context = args.skillContext; + if (args.instructionContext) req.instruction_context = args.instructionContext; + } return req; } 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/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/session_log.ts b/src/core/session_log.ts index aea6c61..d62e14e 100644 --- a/src/core/session_log.ts +++ b/src/core/session_log.ts @@ -15,6 +15,7 @@ import type { BrainEvent } from "./brain_protocol.js"; import type { ToolResult } from "./tool_executor.js"; import { registerRestore } from "../ui/restore.js"; import { normalizeWorkspace } from "./workspace_scope.js"; +import { redactInline, SENSITIVE_KEY } from "./redaction.js"; export function logsRoot(): string { return process.env["AETHER_LOG_DIR"] ?? join(homedir(), ".aether-agent", "logs"); @@ -22,15 +23,6 @@ export function logsRoot(): string { -const SENSITIVE_KEY = /token|secret|password|authorization|api[_-]?key|private[_-]?key|credential|pat/i; - -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); -} - function loggedArgs(args: Record): Record { return Object.fromEntries(Object.entries(args).map(([key, value]) => { if (SENSITIVE_KEY.test(key)) return [key, "[REDACTED]"]; 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/stream.ts b/src/core/stream.ts index cb73c2b..215ccfb 100644 --- a/src/core/stream.ts +++ b/src/core/stream.ts @@ -31,6 +31,12 @@ export type StreamFrameBody = | { type: "session"; sessionId: string; protocolVersion: number; model?: string; tools?: string[] } | { type: "tool_call"; toolCallId: string; name: string; args: Record; risk?: string } | { type: "tool_result_ack"; toolCallId: string } + // Skills & Health context acknowledgements (metadata only — counts/bytes, + // never instruction content). Sent once after the `session` frame when the + // create request carried skill/instruction context and the server accepted, + // clipped, or rejected it. + | { type: "skill_context_ack"; accepted: boolean; count?: number; bytes?: number; reason?: string } + | { type: "instruction_context_ack"; accepted: boolean; count?: number; bytes?: number; reason?: string } // The server-signed chain-of-custody for this turn (commitment + attestation). // The server signs but never stores it — the client decides whether to persist // (the CLI logs it locally; a web client may show-then-discard). @@ -151,6 +157,15 @@ function normalizeFrameBody(obj: Record): StreamFrameBody | nul type: "tool_result_ack", toolCallId: String(obj["tool_call_id"] ?? obj["toolCallId"] ?? ""), }; + case "skill_context_ack": + case "instruction_context_ack": + return { + type, + accepted: obj["accepted"] !== false, + count: numOrUndef(obj["count"]), + bytes: numOrUndef(obj["bytes"]), + reason: strOrUndef(obj["reason"]), + }; case "custody": return { type: "custody", diff --git a/src/core/support_bundle.ts b/src/core/support_bundle.ts new file mode 100644 index 0000000..bd55639 --- /dev/null +++ b/src/core/support_bundle.ts @@ -0,0 +1,290 @@ +// 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 { doctorReportV2, 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 { + const doctor = await doctorReportV2(ctx, { mode: "fast" }, { ...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/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/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/src/main.ts b/src/main.ts index fbe7bca..dc4741b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -67,6 +67,13 @@ async function main(argv: string[]): Promise { yes: { type: "boolean", short: "y", default: false }, apply: { type: "boolean", default: false }, deep: { type: "boolean", default: false }, + // `aether doctor` flags: + network: { type: "boolean", default: false }, + failed: { type: "boolean", default: false }, + fix: { type: "boolean", default: false }, + schema: { type: "string" }, + category: { type: "string" }, + junit: { type: "string" }, help: { type: "boolean", short: "h", default: false }, version: { type: "boolean", short: "v", default: false }, // `aether agent` flags: @@ -77,6 +84,14 @@ async function main(argv: string[]): Promise { quiet: { type: "boolean", default: false }, interactive: { type: "boolean", default: false }, "no-log": { type: "boolean", default: false }, + // `aether skills` flags: + scope: { type: "string" }, + all: { type: "boolean", default: false }, + ci: { type: "boolean", default: false }, + // `aether agent` skill flags + `aether capabilities`: + skill: { type: "string" }, + "no-skills": { type: "boolean", default: false }, + available: { type: "boolean", default: false }, worktree: { type: "boolean", default: false }, repo: { type: "string" }, swarm: { type: "string" }, @@ -144,6 +159,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"]) }); @@ -167,7 +196,20 @@ async function main(argv: string[]): Promise { } case "doctor": { const { cmdDoctor } = await import("./commands/doctor.js"); - return cmdDoctor(ctx, rest, { deep: Boolean(values["deep"]) }); + return cmdDoctor(ctx, rest, { + deep: Boolean(values["deep"]), + network: Boolean(values["network"]), + failed: Boolean(values["failed"]), + fix: Boolean(values["fix"]), + yes: Boolean(values["yes"]), + ...(sf(values["schema"]) != null ? { schema: sf(values["schema"])! } : {}), + ...(sf(values["category"]) != null ? { category: sf(values["category"])! } : {}), + ...(sf(values["junit"]) != null ? { junit: sf(values["junit"])! } : {}), + }); + } + case "support-bundle": { + const { cmdSupportBundle } = await import("./commands/support_bundle.js"); + return cmdSupportBundle(ctx, rest); } case "mcp": { const { cmdMcp } = await import("./commands/mcp.js"); @@ -205,6 +247,8 @@ async function main(argv: string[]): Promise { repo: sf(values["repo"]), swarm: Number(sf(values["swarm"]) ?? "1") || 1, resume: sf(values["resume"]), + skill: sf(values["skill"]), + noSkills: Boolean(values["no-skills"]), }); } case "resume": { 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" + } +} diff --git a/test/autonomy.test.ts b/test/autonomy.test.ts index 59e9fad..a8bfdd0 100644 --- a/test/autonomy.test.ts +++ b/test/autonomy.test.ts @@ -24,6 +24,8 @@ test("gateActionFor gates mutating/shell tools and ignores read-only ones", () = assert.equal(gateActionFor("read_file"), null); assert.equal(gateActionFor("repo_search"), null); assert.equal(gateActionFor("run_tests"), "shell"); + assert.equal(gateActionFor("web_search"), "network"); + assert.equal(gateActionFor("web_fetch"), "network"); assert.equal(gateActionFor("unknown_tool"), null); }); @@ -37,6 +39,11 @@ test("decideGate: ask mode prompts on a TTY, FAILS CLOSED (deny) without one", ( assert.equal(decideGate("run_shell", "ask", false, { yes: false, isTty: false }), "deny"); assert.equal(decideGate("run_tests", "ask", false, { yes: false, isTty: false }), "deny"); assert.equal(decideGate("write_file", "ask", false, { yes: false, isTty: false }), "deny"); + // Network is an explicit gate category — it must not bypass the gate just + // because it mutates nothing locally. + assert.equal(decideGate("web_fetch", "ask", false, { yes: false, isTty: true }), "prompt"); + assert.equal(decideGate("web_fetch", "ask", false, { yes: false, isTty: false }), "deny"); + assert.equal(decideGate("web_search", "ask", false, { yes: false, isTty: false }), "deny"); }); test("decideGate: --yes auto-confirms even on a non-TTY", () => { diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts index 50ab3f6..145313e 100644 --- a/test/diagnostics.test.ts +++ b/test/diagnostics.test.ts @@ -124,6 +124,8 @@ test("default doctor is fast/local, ordered, fail-soft, and content-redacted", a mcpClient: client, timeoutMs: 50, }); + // v2 added backend.capabilities plus the skills/instructions categories; + // the original 12 keep their exact ids and relative order. assert.deepEqual(report.checks.map((check) => check.id), [ "runtime.node", "workspace.directory", @@ -131,12 +133,19 @@ test("default doctor is fast/local, ordered, fail-soft, and content-redacted", a "configuration.transport", "authentication", "backend.catalog", + "backend.capabilities", "tools.schemas", "tools.gates", "memory.health", "mcp.registry", "mcp.broker", "persistence.local", + "skills.index", + "skills.lock", + "skills.trust", + "skills.evals", + "instructions.graph", + "instructions.conflicts", ]); assert.equal(report.checks.find((check) => check.id === "backend.catalog")?.status, "skip"); assert.equal(report.checks.find((check) => check.id === "mcp.broker")?.status, "skip"); diff --git a/test/doctor_v2.test.ts b/test/doctor_v2.test.ts new file mode 100644 index 0000000..51847bc --- /dev/null +++ b/test/doctor_v2.test.ts @@ -0,0 +1,348 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Writable } from "node:stream"; +import { cmdDoctor } from "../src/commands/doctor.js"; +import type { AppContext } from "../src/core/context.js"; +import { doctorReportV2, type DoctorReportV2 } from "../src/core/diagnostics.js"; +import type { McpClient } from "../src/core/mcp.js"; +import { LocalMcpStore } from "../src/core/mcp_store.js"; +import type { MemoryRoots } from "../src/core/memory.js"; + +interface Fixture { + ctx: AppContext; + cwd: string; + roots: MemoryRoots; + store: LocalMcpStore; + client: McpClient; + counters: { backend: number; broker: number; probe: number }; + restore(): void; +} + +function setup(): Fixture { + const root = mkdtempSync(join(tmpdir(), "aether-doctor2-")); + const cwd = join(root, "workspace"); + mkdirSync(cwd); + mkdirSync(join(cwd, ".git")); + const configDir = join(root, "config"); + mkdirSync(configDir, { recursive: true }); + const previousConfig = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = configDir; + const logs = join(root, "logs"); + const snapshots = join(root, "snapshots"); + mkdirSync(logs); + mkdirSync(snapshots); + const goals = join(root, "goals.json"); + writeFileSync(goals, "[]"); + const roots: MemoryRoots = { + logs, + snapshots, + goals, + history: join(root, "history"), + legacyHistory: join(root, "legacy-history"), + }; + const store = new LocalMcpStore(join(root, "mcp.json")); + const counters = { backend: 0, broker: 0, probe: 0 }; + const ctx = { + cfg: { baseUrl: "https://api.example.test" }, + flags: { cwd, json: false, audit: false, yes: false }, + tokens: { get: async () => "credential" }, + api: { + async getJson() { + counters.backend += 1; + return { models: [] }; + }, + }, + confirm: async () => false, + } as unknown as AppContext; + const client = { + async listProviders() { + counters.broker += 1; + return []; + }, + async listConnections() { + counters.broker += 1; + return []; + }, + async listTools() { + counters.broker += 1; + return []; + }, + } as unknown as McpClient; + return { + ctx, + cwd, + roots, + store, + client, + counters, + restore: () => { + if (previousConfig == null) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = previousConfig; + }, + }; +} + +function projectSkill(cwd: string, name: string): void { + const dir = join(cwd, ".aether", "skills", "project", name); + mkdirSync(dir, { recursive: true }); + const manifest = { + schema_version: 1, + id: "project/" + name, + version: "0.1.0", + name, + description: "Fixture skill for doctor tests.", + 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 }, + }; + writeFileSync(join(dir, "skill.json"), JSON.stringify(manifest, null, 2) + "\n", "utf8"); + writeFileSync(join(dir, "SKILL.md"), "# " + name + "\n", "utf8"); +} + +function sink(): { out: Writable; text(): string } { + const chunks: string[] = []; + return { + out: { write: (value: string) => (chunks.push(value), true) } as unknown as Writable, + text: () => chunks.join(""), + }; +} + +function byId(report: DoctorReportV2, id: string) { + return report.checks.find((check) => check.id === id); +} + +test("v2 fast report has the schema shape and never touches the network", async () => { + const fixture = setup(); + try { + const report = await doctorReportV2( + fixture.ctx, + { mode: "fast" }, + { + now: "2026-08-14T00:00:00.000Z", + memoryRoots: fixture.roots, + mcpStore: fixture.store, + mcpClient: fixture.client, + apiProbe: async () => { + fixture.counters.probe += 1; + throw new Error("fast mode must never call the probe"); + }, + }, + ); + assert.equal(report.schema_version, 2); + assert.equal(report.mode, "fast"); + assert.equal(report.generated_at, "2026-08-14T00:00:00.000Z"); + assert.equal(report.capability_contract, null); + for (const check of report.checks) { + assert.deepEqual(check.evidence, { metadata_only: true }); + assert.ok(check.duration_ms >= 0); + assert.ok(["info", "warning", "critical"].includes(check.severity)); + assert.equal(typeof check.configured, "boolean"); + assert.equal(typeof check.reachable, "boolean"); + assert.equal(typeof check.verified, "boolean"); + } + assert.equal(byId(report, "backend.catalog")?.status, "skip"); + assert.equal(byId(report, "backend.capabilities")?.status, "skip"); + assert.equal(byId(report, "mcp.broker")?.status, "skip"); + assert.deepEqual(fixture.counters, { backend: 0, broker: 0, probe: 0 }); + const total = report.checks.length; + const summed = report.summary.pass + report.summary.warn + report.summary.fail + report.summary.skip; + assert.equal(summed, total); + } finally { + fixture.restore(); + } +}); + +test("network mode calls the capability probe and records the contract", async () => { + const fixture = setup(); + try { + const report = await doctorReportV2( + fixture.ctx, + { mode: "network" }, + { + memoryRoots: fixture.roots, + mcpStore: fixture.store, + mcpClient: fixture.client, + timeoutMs: 200, + apiProbe: async () => { + fixture.counters.probe += 1; + return { version: 3, digest: "abc123def456" }; + }, + }, + ); + assert.equal(fixture.counters.probe, 1); + assert.deepEqual(report.capability_contract, { version: 3, digest: "abc123def456" }); + assert.equal(byId(report, "backend.capabilities")?.status, "pass"); + assert.equal(byId(report, "backend.catalog")?.status, "pass"); + } finally { + fixture.restore(); + } +}); + +test("category filter narrows the executed checks", async () => { + const fixture = setup(); + try { + const report = await doctorReportV2( + fixture.ctx, + { mode: "fast", categories: ["runtime", "workspace"] }, + { memoryRoots: fixture.roots, mcpStore: fixture.store, mcpClient: fixture.client }, + ); + assert.deepEqual( + report.checks.map((check) => check.id), + ["runtime.node", "workspace.directory", "workspace.git"], + ); + } finally { + fixture.restore(); + } +}); + +test("--failed emits only warn/fail checks while keeping the full summary", async () => { + const fixture = setup(); + try { + projectSkill(fixture.cwd, "fixture"); // untrusted + unlocked → warns + fixture.ctx.flags.json = true; + const captured = sink(); + const code = await cmdDoctor(fixture.ctx, ["--schema", "v2", "--failed", "--category", "skills"], { + out: captured.out, + dependencies: { memoryRoots: fixture.roots, mcpStore: fixture.store, mcpClient: fixture.client }, + }); + assert.equal(code, 0); + const parsed = JSON.parse(captured.text()) as DoctorReportV2; + assert.ok(parsed.checks.length > 0); + for (const check of parsed.checks) { + assert.ok(check.status === "warn" || check.status === "fail"); + } + assert.ok(parsed.summary.pass + parsed.summary.warn + parsed.summary.fail + parsed.summary.skip > parsed.checks.length); + } finally { + fixture.ctx.flags.json = false; + fixture.restore(); + } +}); + +test("--junit writes a JUnit XML report", async () => { + const fixture = setup(); + try { + const junitPath = join(fixture.cwd, "doctor.xml"); + const captured = sink(); + const code = await cmdDoctor(fixture.ctx, ["--junit", junitPath], { + out: captured.out, + dependencies: { memoryRoots: fixture.roots, mcpStore: fixture.store, mcpClient: fixture.client }, + }); + assert.equal(code, 0); + const xml = readFileSync(junitPath, "utf8"); + assert.match(xml, //); // network checks skip in fast mode + } finally { + fixture.restore(); + } +}); + +test("--json default stays v1-shaped for old consumers", async () => { + const fixture = setup(); + try { + fixture.ctx.flags.json = true; + const captured = sink(); + const code = await cmdDoctor(fixture.ctx, [], { + out: captured.out, + dependencies: { memoryRoots: fixture.roots, mcpStore: fixture.store, mcpClient: fixture.client }, + }); + assert.equal(code, 0); + const parsed = JSON.parse(captured.text()) as Record; + assert.equal(parsed["schemaVersion"], 1); + assert.equal(parsed["deep"], false); + const checks = parsed["checks"] as Record[]; + assert.ok(checks.length > 0); + for (const check of checks) { + assert.equal(typeof check["durationMs"], "number"); + assert.equal(check["severity"], undefined); + assert.equal(check["evidence"], undefined); + } + } finally { + fixture.ctx.flags.json = false; + fixture.restore(); + } +}); + +test("skills checks flag index errors, lock drift, trust, and missing evals", async () => { + const fixture = setup(); + try { + projectSkill(fixture.cwd, "good"); + const broken = join(fixture.cwd, ".aether", "skills", "project", "broken"); + mkdirSync(broken, { recursive: true }); + writeFileSync(join(broken, "skill.json"), "{not json", "utf8"); + // Lock records the skill with a different digest → "changed" drift. + writeFileSync( + join(fixture.cwd, ".aether", "skills.lock.json"), + JSON.stringify({ + schema_version: 1, + skills: [ + { id: "project/good", version: "0.1.0", source: "x", sha256: "0".repeat(64), dependencies: [] }, + ], + }), + "utf8", + ); + const report = await doctorReportV2( + fixture.ctx, + { mode: "fast", categories: ["skills"] }, + { mcpStore: fixture.store, mcpClient: fixture.client }, + ); + assert.equal(byId(report, "skills.index")?.status, "warn"); + assert.match(byId(report, "skills.index")?.detail ?? "", /1 index error/); + assert.equal(byId(report, "skills.lock")?.status, "warn"); + assert.match(byId(report, "skills.lock")?.detail ?? "", /1 changed/); + assert.match(byId(report, "skills.lock")?.detail ?? "", /aether skills lock/); + assert.equal(byId(report, "skills.trust")?.status, "warn"); + assert.match(byId(report, "skills.trust")?.detail ?? "", /untrusted or changed/); + assert.equal(byId(report, "skills.evals")?.status, "warn"); + assert.match(byId(report, "skills.evals")?.detail ?? "", /no eval manifest/); + // Details are metadata-only: never file contents. + for (const check of report.checks) assert.ok(!check.detail.includes("not json")); + } finally { + fixture.restore(); + } +}); + +test("instruction checks report sources and conflicts", async () => { + const fixture = setup(); + try { + writeFileSync(join(fixture.cwd, "AGENTS.md"), "Run `npm test` before pushing.\n", "utf8"); + writeFileSync(join(fixture.cwd, "CLAUDE.md"), "Always verify with `pnpm test`.\n", "utf8"); + const report = await doctorReportV2( + fixture.ctx, + { mode: "fast", categories: ["instructions"] }, + { mcpStore: fixture.store, mcpClient: fixture.client }, + ); + assert.equal(byId(report, "instructions.graph")?.status, "pass"); + assert.match(byId(report, "instructions.graph")?.detail ?? "", /2 instruction source/); + assert.equal(byId(report, "instructions.conflicts")?.status, "warn"); + assert.match(byId(report, "instructions.conflicts")?.detail ?? "", /test command/); + } finally { + fixture.restore(); + } +}); + +test("clean workspace passes skills and instructions checks", async () => { + const fixture = setup(); + try { + const report = await doctorReportV2( + fixture.ctx, + { mode: "fast", categories: ["skills", "instructions"] }, + { mcpStore: fixture.store, mcpClient: fixture.client }, + ); + for (const check of report.checks) { + assert.equal(check.status, "pass", check.id + ": " + check.detail); + } + assert.ok(existsSync(fixture.cwd)); + } finally { + fixture.restore(); + } +}); 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/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/safe_repair.test.ts b/test/safe_repair.test.ts new file mode 100644 index 0000000..8476e27 --- /dev/null +++ b/test/safe_repair.test.ts @@ -0,0 +1,164 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Writable } from "node:stream"; +import { cmdDoctor } from "../src/commands/doctor.js"; +import type { AppContext } from "../src/core/context.js"; +import { + executeRepairs, + planRepairs, + repairReceiptsPath, + type RepairAction, + type RepairPlan, +} from "../src/core/diagnostics/repair.js"; + +const NOW = "2026-08-14T12:00:00.000Z"; + +function setup(): { configDir: string; ctx: AppContext; restore(): void } { + const root = mkdtempSync(join(tmpdir(), "aether-repair-")); + const configDir = join(root, "config"); + mkdirSync(configDir, { recursive: true }); + const previous = process.env["AETHER_CONFIG_DIR"]; + process.env["AETHER_CONFIG_DIR"] = configDir; + const ctx = { + cfg: { baseUrl: "https://api.example.test" }, + flags: { cwd: root, json: false, audit: false, yes: false }, + tokens: { get: async () => null }, + confirm: async () => false, + } as unknown as AppContext; + return { + configDir, + ctx, + restore: () => { + if (previous == null) delete process.env["AETHER_CONFIG_DIR"]; + else process.env["AETHER_CONFIG_DIR"] = previous; + }, + }; +} + +function sink(): { out: Writable; text(): string } { + const chunks: string[] = []; + return { + out: { write: (value: string) => (chunks.push(value), true) } as unknown as Writable, + text: () => chunks.join(""), + }; +} + +test("doctor --fix is a dry run: plan printed, nothing mutated", async () => { + const fixture = setup(); + try { + const corrupt = join(fixture.configDir, "skill-settings.json"); + writeFileSync(corrupt, "{corrupt", "utf8"); + const captured = sink(); + const code = await cmdDoctor(fixture.ctx, ["--fix"], { out: captured.out }); + assert.equal(code, 0); + assert.match(captured.text(), /repair\.skill_index/); + assert.match(captured.text(), /dry run/); + assert.equal(readFileSync(corrupt, "utf8"), "{corrupt"); + assert.equal(existsSync(repairReceiptsPath()), false); + } finally { + fixture.restore(); + } +}); + +test("apply backs up first, repairs the store, and writes a metadata receipt", async () => { + const fixture = setup(); + try { + const corrupt = join(fixture.configDir, "skill-settings.json"); + writeFileSync(corrupt, "{corrupt", "utf8"); + const captured = sink(); + const code = await cmdDoctor(fixture.ctx, ["--fix", "--yes"], { + out: captured.out, + dependencies: { now: NOW }, + }); + assert.equal(code, 0); + const repaired = JSON.parse(readFileSync(corrupt, "utf8")) as Record; + assert.equal(repaired["schema_version"], 1); + assert.deepEqual(repaired["settings"], []); + const backup = corrupt + ".corrupt-" + process.pid; + assert.equal(readFileSync(backup, "utf8"), "{corrupt"); + const receipts = readFileSync(repairReceiptsPath(), "utf8").trim().split("\n"); + assert.equal(receipts.length, 1); + const receipt = JSON.parse(receipts[0]!) as Record; + assert.equal(receipt["repair_id"], "repair.skill_index"); + assert.equal(receipt["target_class"], "skill-index-store"); + assert.equal(receipt["verified"], true); + assert.equal(receipt["ts"], NOW); + assert.match(String(receipt["before_digest"]), /^[0-9a-f]{64}$/); + assert.match(String(receipt["after_digest"]), /^[0-9a-f]{64}$/); + // Metadata-only: the corrupt file's content never lands in a receipt. + assert.equal(receipts[0]!.includes("{corrupt"), false); + } finally { + fixture.restore(); + } +}); + +test("repair.config_dir creates a missing config directory", () => { + const fixture = setup(); + try { + rmSync(fixture.configDir, { recursive: true, force: true }); + const plans = planRepairs(NOW); + assert.deepEqual(plans.map((plan) => plan.repairId), ["repair.config_dir"]); + const outcomes = executeRepairs(plans, NOW); + assert.equal(outcomes[0]?.verified, true); + assert.equal(existsSync(fixture.configDir), true); + } finally { + fixture.restore(); + } +}); + +test("repair.stale_tmp lists only day-old *.tmp files and removes them", () => { + const fixture = setup(); + try { + const stale = join(fixture.configDir, "old.tmp"); + const fresh = join(fixture.configDir, "new.tmp"); + writeFileSync(stale, "x", "utf8"); + writeFileSync(fresh, "x", "utf8"); + const twoDaysAgo = (Date.parse(NOW) - 2 * 24 * 60 * 60 * 1000) / 1000; + utimesSync(stale, twoDaysAgo, twoDaysAgo); + const plans = planRepairs(NOW); + assert.deepEqual(plans.map((plan) => plan.target), [stale]); + executeRepairs(plans, NOW); + assert.equal(existsSync(stale), false); + assert.equal(existsSync(fresh), true); + } finally { + fixture.restore(); + } +}); + +test("verify failure rolls back from the backup and receipts verified:false", () => { + const fixture = setup(); + try { + const target = join(fixture.configDir, "victim.json"); + writeFileSync(target, "original", "utf8"); + const plan: RepairPlan = { + repairId: "repair.test_rollback", + targetClass: "test", + target, + backupPath: target + ".corrupt-" + process.pid, + detail: "test", + }; + const failing: RepairAction = { + id: "repair.test_rollback", + targetClass: "test", + describe: () => "test", + plan: () => [plan], + apply: (p) => writeFileSync(p.target, "mutated", "utf8"), + verify: () => false, + rollback: (p) => { + if (p.backupPath) writeFileSync(p.target, readFileSync(p.backupPath)); + }, + }; + const outcomes = executeRepairs([plan], NOW, [failing]); + assert.equal(outcomes[0]?.applied, true); + assert.equal(outcomes[0]?.verified, false); + assert.equal(outcomes[0]?.rolledBack, true); + assert.equal(readFileSync(target, "utf8"), "original"); + const receipt = JSON.parse(readFileSync(repairReceiptsPath(), "utf8").trim()) as Record; + assert.equal(receipt["verified"], false); + } finally { + fixture.restore(); + } +}); 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); + }); +}); 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(); + } +});