mirror of
https://github.com/Heretek-AI/opencode-rea.git
synced 2026-11-07 10:56:10 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
974c8dd85a | ||
|
|
657a3aef1b | ||
|
|
f2aff44ea9 | ||
|
|
d9dd1ebbb8 |
No files matched your search
@@ -0,0 +1,38 @@
|
||||
name: Publish to npm (trusted publishing)
|
||||
|
||||
# OIDC trusted publishing: no long-lived npm token. Configure once at
|
||||
# https://www.npmjs.com/package/@heretek-ai/opencode-rea/access
|
||||
# (Trusted Publisher -> GitHub Actions -> Heretek-AI/opencode-rea,
|
||||
# workflow publish.yml), then every published GitHub release ships to npm
|
||||
# with provenance automatically.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish via OIDC
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Typecheck
|
||||
run: bun run check
|
||||
- name: Run unit tests
|
||||
run: bun test --path-ignore-patterns 'review/**' test/unit
|
||||
- name: Build plugin
|
||||
run: bun run build
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
@@ -35,14 +35,15 @@
|
||||
edits an MCP server, never calls `ctx.mcp.transform`), install
|
||||
(`opencode plugin add <package or git spec>`, package `@heretek-ai/opencode-rea`),
|
||||
options (`server` default `"rea"`, `runtime` `"ask"|"allow"|"deny"` default
|
||||
`"ask"`; see `src/options.ts`), agents/skills/commands/readiness (sources of
|
||||
truth: `src/agents.ts` + `agents/*.md`, `src/skills.ts` + `skills/*/SKILL.md`,
|
||||
`src/commands.ts`, `src/readiness.ts`).
|
||||
`"ask"`, optional `codemode` boolean; see `src/options.ts`), agents/skills/commands/readiness
|
||||
(sources of truth: `src/agents.ts` + `agents/*.md`, `src/skills.ts` + `skills/*/SKILL.md`,
|
||||
`src/commands.ts`, `src/readiness.ts`, `src/effects.ts` with 31 runtime-effect tools for
|
||||
`rea-agents@6.5.0`).
|
||||
- Docs accuracy is machine-checked by `test/unit/docs.test.ts` (README identity
|
||||
vs `package.json`, agent/skill files on disk, commands vs `src/commands.ts`
|
||||
exports, options/readiness contract vs `src/options.ts`/`src/readiness.ts`).
|
||||
|
||||
## Host Facts Proven (T02)
|
||||
## Host Facts Proven (T02 & v2 Harness Updates)
|
||||
|
||||
Real-toolchain harness: `test/support/model-fixture.ts` (loopback OpenAI-compatible
|
||||
HTTP server, real `node:http`, records every request, redacts auth) +
|
||||
@@ -75,3 +76,9 @@ Only the LLM is scripted; every report path notes live-model behaviour unverifie
|
||||
`opencode-ai@1.18.35` POSIX only; T02 proves V2 `opencode v2.0.26` on linux
|
||||
(plus E2E smoke `test/e2e/smoke.test.ts`: built plugin loads, run exits 0,
|
||||
no `LoadError` in server logs).
|
||||
- F7 PASS (`src/agents.ts` `evaluateAgentPermission`): OpenCode v2's
|
||||
`ConfigAgentPlugin` runs in `post` and appends top-level `opencode.json`
|
||||
wildcard `permissions` after plugin transforms; hooking
|
||||
`ctx.permission.hook("evaluate", ...)` preserves `rea-*` agent boundaries
|
||||
even under global `allow-all` while still respecting explicit non-wildcard
|
||||
`agents.<id>.permissions` overrides.
|
||||
@@ -1,20 +1,21 @@
|
||||
# opencode-rea
|
||||
|
||||
OpenCode v2 plugin that wires [REA](https://github.com/morluto/rea) reverse-engineering
|
||||
agents, skills, slash commands, and a readiness tool into OpenCode — on top of an
|
||||
already-registered REA MCP server.
|
||||
agents, skills, slash commands, Code Mode catalog pinning, and a readiness tool
|
||||
into OpenCode — on top of an already-registered REA MCP server.
|
||||
|
||||
- Package: `@heretek-ai/opencode-rea`
|
||||
- Version: `0.1.0`
|
||||
- Version: `0.1.2`
|
||||
- Entry: `./dist/index.js` (`exports: { ".": "./dist/index.js" }`)
|
||||
- Shipped files: `dist`, `skills`, `agents`, `index.js`
|
||||
(`index.js` at the repo root re-exports `./dist/index.js`: the host resolves a
|
||||
local plugin directory via a root `index.js`, so a packed+extracted directory
|
||||
without it is silently never loaded)
|
||||
- Peer: `@opencode/plugin@^2` (dev: `2.0.26`; also dev: `@opencode/cli`/`@opencode/sdk`
|
||||
`2.0.26`, `rea-agents` `6.4.0`)
|
||||
`2.0.26`, `rea-agents` `6.5.0`)
|
||||
- Plugin id: `rea` (`src/index.ts`: `Plugin.define({ id: "rea", setup ... })`;
|
||||
setup parses options, then registers skills, agents, readiness, commands)
|
||||
setup parses options, then registers skills, agents, readiness + tool catalog
|
||||
policy, and commands)
|
||||
|
||||
Machine-checkable docs guard: `test/unit/docs.test.ts`
|
||||
(build/test rules: `AGENTS.md`).
|
||||
@@ -46,10 +47,11 @@ opencode plugin add @heretek-ai/opencode-rea
|
||||
|
||||
Configure under the plugin's options (keyed by plugin id `rea`):
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|-----------|----------------------------|---------|----------------------------------------------------------------|
|
||||
| `server` | string (non-empty) | `"rea"` | MCP server name; permission actions and tool counts use the host-sanitized namespace (`server.replace(/[^a-zA-Z0-9_-]/g, "_")` + `_<tool>`) |
|
||||
| `runtime` | `"ask"` \| `"allow"` \| `"deny"` | `"ask"` | Approval policy applied to the 21 runtime-effect tools (see Agents) |
|
||||
| Key | Type | Default | Meaning |
|
||||
|------------|-----------------------------------|---------|---------|
|
||||
| `server` | string (non-empty) | `"rea"` | MCP server name; permission actions and tool counts use the host-sanitized namespace (`server.replace(/[^a-zA-Z0-9_-]/g, "_")` + `_<tool>`) |
|
||||
| `runtime` | `"ask"` \| `"allow"` \| `"deny"` | `"ask"` | Approval policy applied to the 31 runtime-effect tools (see Agents) |
|
||||
| `codemode` | `boolean` (optional) | omitted | When explicitly set (`true` or `false`), applies `options.codemode` to all `<sanitized-server>_*` MCP tools via `ctx.tool.transform` without mutating `opencode.json` |
|
||||
|
||||
Validation (`src/options.ts`, `parseOptions`):
|
||||
|
||||
@@ -59,44 +61,58 @@ Validation (`src/options.ts`, `parseOptions`):
|
||||
- `invalid plugin option "server": expected a string, got <typeof>`
|
||||
- empty/whitespace-only `server` fails setup: `invalid plugin option "server": expected a non-empty string, got <value>`
|
||||
- `invalid plugin option "runtime": expected one of "ask"|"allow"|"deny", got <value>`
|
||||
- `invalid plugin option "codemode": expected a boolean, got <typeof>`
|
||||
- A non-object (e.g. an array) fails setup:
|
||||
`invalid plugin options: expected an object, got <array|typeof>`.
|
||||
|
||||
## Agents
|
||||
|
||||
Source of truth: `src/agents.ts` + `agents/*.md` (registered via
|
||||
`ctx.agent.transform` `update`, which upserts; host defaults and any later
|
||||
`agents.<id>` config rules still win via last-match evaluation).
|
||||
`ctx.agent.transform` `update`, which upserts, plus a `ctx.permission.hook("evaluate", ...)`
|
||||
handler that enforces agent boundaries even when top-level `opencode.json`
|
||||
wildcard `permissions` are appended after plugin transforms by `ConfigAgentPlugin`,
|
||||
while still honoring explicit non-wildcard `agents.<id>.permissions` overrides).
|
||||
|
||||
| Agent | File | Mode | Role |
|
||||
|---------------|---------------------|------------|------|
|
||||
| `rea` | `agents/rea.md` | `primary` | Lead investigator. Owns the investigation end to end: plans, runs, keeps the finding ledger, writes the final report, delegates bounded scopes to subagents. |
|
||||
| Agent | File | Mode | Role |
|
||||
|---------------|-------------------------|------------|------|
|
||||
| `rea` | `agents/rea.md` | `primary` | Lead investigator. Owns the investigation end to end: plans, runs, keeps the finding ledger, writes the final report, delegates bounded scopes to subagents. |
|
||||
| `rea-analyst` | `agents/rea-analyst.md` | `subagent` | Static-only analyst. One bounded question per assignment from static evidence (bytes, addresses, strings, graph edges, saved DBs, bookmarks). |
|
||||
| `rea-runtime` | `agents/rea-runtime.md` | `subagent` | Runtime observer. Answers what static evidence cannot by observing execution within the declared target scope. |
|
||||
| `rea-auditor` | `agents/rea-auditor.md` | `subagent` | Evidence auditor. Verifies findings against the observations/inferences/unknowns discipline; verifies, never redoes. |
|
||||
|
||||
Edit / subagent / shell / runtime-effect gating (`buildAgentPermissions`, `src/agents.ts`;
|
||||
permission actions for REA tools use the `<sanitized-server>_<tool>` shape, resource `"*"`):
|
||||
Edit / subagent / shell / runtime-effect gating (`buildAgentPermissions` and
|
||||
`evaluateAgentPermission`, `src/agents.ts`; permission actions for REA tools use
|
||||
the `<sanitized-server>_<tool>` shape, resource `"*"`):
|
||||
|
||||
- `rea`: keeps defaults — **no** `edit`/`subagent`/`shell` denies. Only the 21
|
||||
- `rea`: keeps defaults — **no** `edit`/`subagent`/`shell` denies. Only the 31
|
||||
runtime-effect tools (`src/effects.ts`, `RUNTIME_EFFECT_TOOLS`) gate on the
|
||||
`runtime` option (`effect: options.runtime`).
|
||||
- `rea-analyst`: static-only — `edit → deny`, `subagent → deny`, `shell → deny`, **and** all 21
|
||||
- `rea-analyst`: static-only — `edit → deny`, `subagent → deny`, `shell → deny`, **and** all 31
|
||||
runtime-effect tools → `deny` regardless of the `runtime` option.
|
||||
- `rea-runtime`: no edits, no subagents, no shell (`edit → deny`, `subagent → deny`, `shell → deny`), but
|
||||
runtime-effect tools gate on the `runtime` option.
|
||||
- `rea-auditor`: read-only — `edit → deny`, `subagent → deny`, `shell → deny`, all 21
|
||||
- `rea-auditor`: read-only — `edit → deny`, `subagent → deny`, `shell → deny`, all 31
|
||||
runtime-effect tools → `deny`.
|
||||
|
||||
The 21 runtime-effect tools (`src/effects.ts`, data-loss/device/foreign-code axis
|
||||
only) are, with `<server> = rea` shown as `rea_<tool>`: `rea_annotate_native_function`,
|
||||
The 31 runtime-effect tools (`src/effects.ts`, data-loss/device/foreign-code axis
|
||||
matching `[email protected]` `destructiveHint: true` 1:1) are, with `<server> = rea`
|
||||
shown as `rea_<tool>`: `rea_annotate_native_function`,
|
||||
`rea_capture_browser_scenario`, `rea_capture_electron_scenario`,
|
||||
`rea_capture_native_ui_scenario`, `rea_capture_process_scenario`, `rea_close_binary`,
|
||||
`rea_export_evidence_bundle`, `rea_install_adb_package`, `rea_observe_native_calls`,
|
||||
`rea_observe_web_execution`, `rea_push_adb_file`, `rea_set_address_name`,
|
||||
`rea_close_gdb_session`, `rea_close_rizin_debug_session`, `rea_cutter_command`,
|
||||
`rea_execute_rizin_command`, `rea_export_evidence_bundle`, `rea_gdb_console`,
|
||||
`rea_install_adb_package`, `rea_instrument_with_frida`, `rea_load_frida_script`,
|
||||
`rea_observe_native_calls`, `rea_observe_web_execution`, `rea_push_adb_file`,
|
||||
`rea_resume_frida_session`, `rea_rizin_debug_command`, `rea_set_address_name`,
|
||||
`rea_set_addresses_names`, `rea_set_bookmark`, `rea_set_comment`,
|
||||
`rea_set_inline_comment`, `rea_start_adb_activity`, `rea_start_adb_app`,
|
||||
`rea_stop_adb_app`, `rea_uninstall_adb_package`, `rea_unset_bookmark`.
|
||||
`rea_start_rizin_debug_session`, `rea_stop_adb_app`, `rea_uninstall_adb_package`,
|
||||
`rea_unset_bookmark`.
|
||||
|
||||
Interactive debugger/Frida session lifecycle tools whose upstream annotation is
|
||||
`destructiveHint: false` are also exported in `DEBUGGER_SESSION_TOOLS`
|
||||
(`close_frida_session`, `start_frida_session`, `start_gdb_session`,
|
||||
`unload_frida_script`).
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -105,12 +121,12 @@ Source of truth: `src/skills.ts` + `skills/*/SKILL.md` (registered via
|
||||
where `content` is the body after frontmatter and `path` is the absolute
|
||||
`SKILL.md` path so sibling references resolve).
|
||||
|
||||
| Skill | File | When to use |
|
||||
|-----------------------|-----------------------------------|-------------|
|
||||
| `rea-opencode` | `skills/rea-opencode/SKILL.md` | Working with REA inside OpenCode v2: `rea_` tool names, permission actions, Code Mode `execute`/`search` fallback, `/rea:` commands, recovery. |
|
||||
| `rea-investigation` | `skills/rea-investigation/SKILL.md` | Multi-step investigation spanning features/subsystems: staged workflow, subagent delegation, finding ledger. |
|
||||
| Skill | File | When to use |
|
||||
|-----------------------|---------------------------------------|-------------|
|
||||
| `rea-opencode` | `skills/rea-opencode/SKILL.md` | Working with REA inside OpenCode v2: `rea_` tool names, permission actions, Code Mode `execute`/`search` vs `codemode: false` direct mode, compact-first views (`detail: "summary"` + `inspect_analysis_view`), `/rea:` commands, recovery. |
|
||||
| `rea-investigation` | `skills/rea-investigation/SKILL.md` | Multi-step investigation spanning features/subsystems: staged workflow, subagent delegation, finding ledger. |
|
||||
| `rea-evidence-report` | `skills/rea-evidence-report/SKILL.md` | Writing up findings: observations vs inferences vs unknowns, Evidence ID citations. |
|
||||
| `rea-reconstruction` | `skills/rea-reconstruction/SKILL.md` | Turning findings into an implementation: findings→build-step mapping, observed-behavior vs design-choice separation, verification obligations. |
|
||||
| `rea-reconstruction` | `skills/rea-reconstruction/SKILL.md` | Turning findings into an implementation: findings→build-step mapping, observed-behavior vs design-choice separation, verification obligations. |
|
||||
|
||||
Upstream note: the plugin does **NOT** vendor the upstream
|
||||
`reverse-engineer-anything` skill. `rea-opencode` states it does not replace that
|
||||
@@ -122,11 +138,11 @@ tool reports whether `reverse-engineer-anything` is visible to the host.
|
||||
|
||||
Source of truth: `src/commands.ts` (registered via `ctx.command.transform`).
|
||||
|
||||
| Command | Description |
|
||||
|--------------|-------------|
|
||||
| `/rea` | Switch to the `rea` investigator agent and run a staged investigation (`rea-investigation` skill: scope → gather with evidence → infer only from cited evidence). |
|
||||
| `/rea-doctor`| Run the REA readiness check by calling the `rea_readiness` tool; report each item and act on the returned remediation. |
|
||||
| `/rea-report`| Produce the REA evidence report (observations vs inferences vs unknowns, every claim with its Evidence ID), then delegate a check to the `rea-auditor` agent. |
|
||||
| Command | Description |
|
||||
|---------------|-------------|
|
||||
| `/rea` | Switch to the `rea` investigator agent and run a staged investigation (`rea-investigation` skill: scope → gather with evidence → infer only from cited evidence). |
|
||||
| `/rea-doctor` | Run the REA readiness check by calling the `rea_readiness` tool; report each item and act on the returned remediation. |
|
||||
| `/rea-report` | Produce the REA evidence report (observations vs inferences vs unknowns, every claim with its Evidence ID), then delegate a check to the `rea-auditor` agent. |
|
||||
|
||||
REA's six MCP prompts surface as host commands under the `rea:<prompt>` namespace
|
||||
and are **not** duplicated by this plugin (`COMMAND_IDS` contain no `":"`, so the
|
||||
@@ -139,13 +155,27 @@ three slash commands never shadow them):
|
||||
- `rea:audit_residual_unknowns`
|
||||
- `rea:prepare_bounded_process_capture`
|
||||
|
||||
## Readiness tool: `rea_readiness`
|
||||
## Readiness tool & Code Mode catalog policy: `rea_readiness`
|
||||
|
||||
Source of truth: `src/readiness.ts` (registered via `ctx.tool.transform` as a
|
||||
native tool with `codemode: false`; read-only — no writes, no config mutation).
|
||||
During the same `ctx.tool.transform` pass, `applyReaToolCatalogPolicy`:
|
||||
|
||||
1. Registers a namespace description (`editor.namespace({ name, description: REA_NAMESPACE_DESCRIPTION })`)
|
||||
for OpenCode v2's Code Mode catalog.
|
||||
2. Pins the 10 primary REA entry-point tools (`PINNED_REA_TOOLS`:
|
||||
`analyze_javascript_application`, `binary_session`, `inspect_analysis_view`,
|
||||
`inspect_android_package`, `inspect_artifact`, `inspect_binary_layout`,
|
||||
`inspect_managed_artifact`, `list_unknowns`, `open_binary`, `record_unknown`)
|
||||
so OpenCode v2's 2,000-token Code Mode catalog summary always surfaces them
|
||||
inline.
|
||||
3. Applies `options.codemode` (when explicitly configured as `true` or `false`)
|
||||
across all `<sanitized-server>_*` MCP tools.
|
||||
|
||||
`rea_readiness` contract:
|
||||
|
||||
- Input: `{}` (empty struct).
|
||||
- Output: `{ server, status, statusError, reaToolCount, upstreamSkillAvailable, version, remediation }`.
|
||||
- Output: `{ server, status, statusError, reaToolCount, codemodeToolCount, directToolCount, upstreamSkillAvailable, version, remediation }`.
|
||||
- What it reports:
|
||||
- `server`: the configured server name (default `"rea"`).
|
||||
- `status` / `statusError`: live MCP status for that server from `ctx.mcp.list()`
|
||||
@@ -153,6 +183,8 @@ native tool with `codemode: false`; read-only — no writes, no config mutation)
|
||||
- `reaToolCount`: number of MCP tools in `ctx.tool.list()` whose `id`/`name` starts
|
||||
with `<sanitized-server>_` (default the `rea_` count), excluding the plugin's
|
||||
own `rea_readiness` tool (0 when the MCP server is absent).
|
||||
- `codemodeToolCount` / `directToolCount`: breakdown of those REA MCP tools by
|
||||
whether `options.codemode` is enabled (`!== false`) or disabled (`=== false`).
|
||||
- `upstreamSkillAvailable`: whether `reverse-engineer-anything` is visible in
|
||||
`ctx.skill.list()`.
|
||||
- `version`: plugin version read from `package.json` (falls back to `"unknown"`,
|
||||
@@ -160,14 +192,15 @@ native tool with `codemode: false`; read-only — no writes, no config mutation)
|
||||
- `remediation`: advice strings **only** (never executed or written by the tool):
|
||||
`rea doctor --client opencode --json` and `rea setup --client opencode --dry-run`.
|
||||
- Human summary `content`: e.g.
|
||||
`rea_readiness: server "rea" status "connected", 170 rea_ tool(s), upstream skill available, version 0.1.0. Remediation: rea doctor --client opencode --json ; rea setup --client opencode --dry-run`.
|
||||
`rea_readiness: server "rea" status "connected", 191 rea_ tool(s) (191 codemode, 0 direct), upstream skill available, version 0.1.0. Remediation: rea doctor --client opencode --json ; rea setup --client opencode --dry-run`.
|
||||
|
||||
Use it via `/rea-doctor`, or call `rea_readiness` directly.
|
||||
|
||||
## What this plugin deliberately does NOT do
|
||||
|
||||
- **No MCP registration or config writes.** Setup only calls `agent`/`skill`/`tool`/
|
||||
`command` transforms plus read-only `mcp.list()`/`tool.list()`/`skill.list()`.
|
||||
`command` transforms (and `permission.hook("evaluate", ...)` when available)
|
||||
plus read-only `mcp.list()`/`tool.list()`/`skill.list()`.
|
||||
It never calls `ctx.mcp.transform` and never touches MCP server configuration —
|
||||
run `rea setup --client opencode` yourself (first) for that.
|
||||
- **Remediation is advice text only.** `rea_readiness` returns the strings
|
||||
|
||||
+10
-6
@@ -10,18 +10,22 @@ never perform runtime observation.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Read-only: you do not edit, write, or patch files, and you do not spawn
|
||||
subagents. If a question needs implementation or delegation, return the
|
||||
evidence and say so instead.
|
||||
- Read-only: you do not edit, write, or patch files, you do not run shell
|
||||
commands, and you do not spawn subagents. If a question needs
|
||||
implementation or delegation, return the evidence and say so instead.
|
||||
- Static evidence only: bytes, addresses, strings, graph edges, saved
|
||||
analysis databases, bookmarks, and packaged resources. Runtime-effect
|
||||
tools are denied to you; if static evidence cannot answer the question,
|
||||
report that gap rather than working around it.
|
||||
analysis databases, bookmarks, and packaged resources. All 31
|
||||
runtime-effect tools (data-discarding teardowns, analysis-DB mutations,
|
||||
device mutations, and scenario/debugger/Frida execution) are denied to
|
||||
you; if static evidence cannot answer the question, report that gap rather
|
||||
than working around it.
|
||||
|
||||
## Method (rea-investigation, rea-evidence-report)
|
||||
|
||||
- Start from the Evidence IDs in your assignment, not from a fresh overview.
|
||||
Do not open a new REA session when usable state already exists.
|
||||
- Prefer compact views (`detail: "summary"`) first and page deeper slices
|
||||
with `inspect_analysis_view` using the retained parent `evidence_id`.
|
||||
- Batch related operations around your hypothesis and expand only when the
|
||||
returned evidence leaves a concrete gap.
|
||||
- Return sources, conclusions with confidence and evidence type, and
|
||||
|
||||
@@ -5,8 +5,8 @@ description: REA evidence auditor. Reviews findings against the observations-inf
|
||||
# REA Evidence Auditor
|
||||
|
||||
You are the evidence auditor. You review investigation findings before they
|
||||
reach the user. You verify, you do not redo: read-only, no edits, no
|
||||
subagents, no runtime tools.
|
||||
reach the user. You verify, you do not redo: read-only, no edits, no shell,
|
||||
no subagents, no runtime-effect tools.
|
||||
|
||||
## Audit checklist (rea-evidence-report)
|
||||
|
||||
@@ -15,8 +15,10 @@ subagents, no runtime tools.
|
||||
- Observations, inferences, and unknowns are labeled as such. Inferences
|
||||
carry confidence and the evidence type that supports them; static-only
|
||||
support for a runtime claim fails the audit.
|
||||
- Raw locations and identity fields (digests, addresses, ordinals) are
|
||||
preserved verbatim, never lossily reformatted.
|
||||
- Raw locations and identity fields (digests, addresses, ordinals, node IDs,
|
||||
graph IDs) are preserved verbatim, never lossily reformatted.
|
||||
- Preview or summary records (`detail: "summary"`) are never treated as
|
||||
complete captures unless paged via `inspect_analysis_view` or exported.
|
||||
- Unknowns list the search boundary that produced them. Unresolved identity
|
||||
or applicability stays unknown — never presented as absent, foreign, or
|
||||
aligned. Bounded negative searches stay bounded.
|
||||
|
||||
+15
-10
@@ -6,27 +6,32 @@ description: REA runtime observer. Runs caller-approved runtime observation with
|
||||
|
||||
You are the runtime-observation specialist. You answer questions that static
|
||||
evidence cannot: what the target actually does when executed. You never
|
||||
modify files and never spawn subagents.
|
||||
modify files, never run arbitrary shell commands, and never spawn subagents.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Runtime requests execute the declared target and lifecycle only. Do not
|
||||
broaden the target or action beyond the caller-selected fields (paths,
|
||||
digests, output directories); preserve them verbatim.
|
||||
- You do not edit, write, or patch files, and you do not spawn subagents.
|
||||
Runtime-effect tools gate on the host's approval policy: if a call is
|
||||
blocked, do not repeat it unchanged — diagnose the observed blocker and
|
||||
resume after repair.
|
||||
- Close any opened native session with the appropriate teardown call when
|
||||
the observation is complete.
|
||||
digests, output directories, PIDs, package names); preserve them verbatim.
|
||||
- You do not edit, write, or patch files, you do not run shell commands, and
|
||||
you do not spawn subagents. Runtime-effect tools (scenario captures,
|
||||
interactive Frida/GDB/Rizin debug commands, Cutter commands, and ADB device
|
||||
actions) gate on the host's approval policy: if a call is blocked, do not
|
||||
repeat it unchanged — diagnose the observed blocker and resume after
|
||||
repair.
|
||||
- Close any opened binary or interactive debugger/instrumentation session
|
||||
with the appropriate teardown call (`close_binary`, `close_frida_session`,
|
||||
`close_gdb_session`, `close_rizin_debug_session`, `unload_frida_script`)
|
||||
when the observation is complete.
|
||||
|
||||
## Method (rea-investigation, rea-evidence-report)
|
||||
|
||||
- Start from the Evidence IDs in your assignment and the hypothesis static
|
||||
evidence left open. Observe the minimum execution that closes the gap.
|
||||
- Captured records may be truncated by the transport or the client: mark
|
||||
previews as previews, page from a summary view via the retained parent
|
||||
Evidence ID, or export the complete record before concluding.
|
||||
previews as previews, page from a summary view via `inspect_analysis_view`
|
||||
using the retained parent `evidence_id`, or export the complete record
|
||||
before concluding.
|
||||
- Report observed behavior separately from what static findings already
|
||||
established. Static evidence alone never establishes runtime behavior,
|
||||
and one passing capture does not prove general behavior.
|
||||
|
||||
+16
-9
@@ -15,18 +15,24 @@ investigation end to end: plan it, run it, and report it.
|
||||
2. Reuse existing REA session state, artifact identity, saved analysis
|
||||
databases, bookmarks, and prior evidence. Do not open duplicate sessions
|
||||
or repeat identical analysis.
|
||||
3. Start with the smallest useful overview or inventory, then follow each
|
||||
question from its entry point to its result. Corroborate conclusions with
|
||||
the evidence type they require; use runtime observation only when static
|
||||
evidence cannot answer the question.
|
||||
4. Revisit the checklist before finishing. Mark each question answered,
|
||||
3. Start with the smallest useful overview or inventory (`detail: "summary"`
|
||||
first, then page specific slices via `inspect_analysis_view` using the
|
||||
retained parent `evidence_id`), and follow each question from its entry
|
||||
point to its result. Corroborate conclusions with the evidence type they
|
||||
require; use runtime observation only when static evidence cannot answer
|
||||
the question.
|
||||
4. Close any opened binary or interactive debugger/instrumentation sessions
|
||||
(`close_binary`, `close_frida_session`, `close_gdb_session`,
|
||||
`close_rizin_debug_session`, `unload_frida_script`) when analysis of that
|
||||
target finishes.
|
||||
5. Revisit the checklist before finishing. Mark each question answered,
|
||||
partially answered, or unresolved with the gap that blocks it.
|
||||
|
||||
## Delegation
|
||||
|
||||
- Decompose independent questions into bounded scopes (one question, one
|
||||
entry point) and delegate them to the rea-analyst, rea-runtime, or
|
||||
rea-auditor subagents with the Evidence IDs they should start from.
|
||||
entry point) and delegate them to the `rea-analyst`, `rea-runtime`, or
|
||||
`rea-auditor` subagents with the Evidence IDs they should start from.
|
||||
- Workers must not re-run the overview or open new sessions when usable
|
||||
state already exists. You keep the finding ledger and the final report.
|
||||
|
||||
@@ -36,5 +42,6 @@ investigation end to end: plan it, run it, and report it.
|
||||
for every claim; never imply static analysis observed execution.
|
||||
- When the user asks to build from findings, map ledger conclusions to build
|
||||
steps, label design choices as such, and attach verification obligations.
|
||||
- REA tools surface with an `rea_` prefix (rea-opencode); resolve tool and
|
||||
command names through the host's actual lists, never by guessing.
|
||||
- REA tools surface with an `rea_` prefix in direct mode or under
|
||||
`tools.rea.<tool>` in OpenCode v2 Code Mode (`rea-opencode`); resolve tool
|
||||
and command names through the host's actual lists, never by guessing.
|
||||
@@ -4,3 +4,4 @@
|
||||
// 12 builtin tools only, no rea_readiness; with this re-export: 13 tools,
|
||||
// rea_readiness completes). Same shape as the T11 wrapper / T12 staging fix.
|
||||
export { default } from "./dist/index.js";
|
||||
export * from "./dist/index.js";
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@heretek-ai/opencode-rea",
|
||||
"version": "0.1.0",
|
||||
"description": "opencode-rea plugin",
|
||||
"name": "opencode-rea",
|
||||
"version": "0.1.2",
|
||||
"description": "OpenCode v2 plugin for REA (reverse-engineer-anything) MCP integration",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -38,7 +38,7 @@
|
||||
"@opencode/plugin": "2.0.26",
|
||||
"@opencode/sdk": "2.0.26",
|
||||
"@types/bun": "1.3.4",
|
||||
"rea-agents": "6.4.0",
|
||||
"rea-agents": "6.5.0",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
|
||||
@@ -16,10 +16,12 @@ and never imply that static analysis observed execution.
|
||||
- Quote the retained Evidence ID for each observation, plus the operation and
|
||||
target it came from so the claim can be re-paged later.
|
||||
- Preserve the producer's raw locations and identity fields verbatim. Do not
|
||||
reformat digests, addresses, or ordinals into a lossy form.
|
||||
- When a result was truncated by the transport or the client, mark the
|
||||
observation as a preview: use the retained parent ID for smaller views or
|
||||
export the complete record before drawing conclusions from it.
|
||||
reformat digests, addresses, node IDs, or ordinals into a lossy form.
|
||||
- When a result was returned as `detail: "summary"` or truncated by the
|
||||
transport or the client, mark the observation as a preview: page via
|
||||
`inspect_analysis_view` using the retained parent `evidence_id` for smaller
|
||||
views or export the complete record (`export_evidence_bundle`) before
|
||||
drawing conclusions from it.
|
||||
|
||||
## Inferences
|
||||
|
||||
|
||||
@@ -17,16 +17,21 @@ instead of running the full staged workflow.
|
||||
2. Inspect the current REA session, artifact identity, saved analysis
|
||||
database, bookmarks, and prior evidence. Reuse matching state; do not
|
||||
open duplicate sessions or repeat identical analysis.
|
||||
3. Start with the smallest useful overview or inventory. Follow each question
|
||||
from its entry point through relevant data and state changes to its
|
||||
result. Batch related operations around a specific hypothesis, then expand
|
||||
only when the returned evidence leaves a concrete gap.
|
||||
3. Start with the smallest useful overview or inventory (`detail: "summary"`
|
||||
where supported, paging targeted slices with `inspect_analysis_view` via
|
||||
the retained parent `evidence_id`). Follow each question from its entry
|
||||
point through relevant data and state changes to its result. Batch related
|
||||
operations around a specific hypothesis, then expand only when the
|
||||
returned evidence leaves a concrete gap.
|
||||
4. Inspect relevant packaged resources and configuration alongside code when
|
||||
they affect the question. Use format-aware inventory and parsers; do not
|
||||
infer behavior from filenames, strings, or layout alone.
|
||||
5. Corroborate a conclusion with the evidence type it requires. Use runtime
|
||||
observation only when static evidence cannot answer the question and the
|
||||
required host runtime and OS access are available.
|
||||
observation (scenario capture or interactive Frida/GDB/Rizin debugging)
|
||||
only when static evidence cannot answer the question and the required host
|
||||
runtime and OS access are available. Close any opened binary or debugger
|
||||
session (`close_binary`, `close_frida_session`, `close_gdb_session`,
|
||||
`close_rizin_debug_session`, `unload_frida_script`) once its scope finishes.
|
||||
6. Revisit the original checklist before finishing. Mark each question as
|
||||
answered, partially answered, or unresolved based on its evidence; keep
|
||||
bounded negative searches bounded.
|
||||
@@ -37,9 +42,11 @@ instead of running the full staged workflow.
|
||||
available and the questions do not depend on one another, assign distinct
|
||||
scopes, point workers at existing evidence, and ask them to return sources,
|
||||
conclusions, and unresolved gaps. Otherwise work sequentially.
|
||||
- Give each worker a bounded scope (one question, one entry point) and the
|
||||
Evidence IDs it should start from. Workers must not re-run the overview or
|
||||
open new sessions when usable state already exists.
|
||||
- Give each worker (`rea-analyst` for static questions, `rea-runtime` for
|
||||
runtime observation, `rea-auditor` for evidence verification) a bounded
|
||||
scope (one question, one entry point) and the Evidence IDs it should start
|
||||
from. Workers must not re-run the overview or open new sessions when usable
|
||||
state already exists.
|
||||
- If the user names only "my app" without a name, path, or already-selected
|
||||
target, ask which app before searching for artifacts. Never choose an
|
||||
example app on the user's behalf.
|
||||
|
||||
@@ -13,45 +13,87 @@ OpenCode-specific surface around it.
|
||||
## Tool names and permissions
|
||||
|
||||
- REA MCP tools surface in OpenCode with an `rea_` prefix (the `rea_<tool>`
|
||||
pattern). Always use the connected server's actual tool list and input
|
||||
schemas; a skill installed from repository main may describe capabilities
|
||||
absent from an older npm release.
|
||||
- Permission actions for REA tools follow the host's permission model. A
|
||||
client can independently require approval for calls the server would
|
||||
otherwise run directly; follow the declared request scope and the host's
|
||||
actual access requirements.
|
||||
pattern, or `<sanitized-server>_<tool>` when `options.server` is customized).
|
||||
Always use the connected server's actual tool list and input schemas; a
|
||||
skill installed from repository main may describe capabilities absent from
|
||||
an older npm release.
|
||||
- Permission actions for REA tools follow the `<sanitized-server>_<tool>`
|
||||
pattern (`resource: "*"`). The 31 destructive / runtime-effect tools
|
||||
(scenario captures, interactive Frida/GDB/Rizin debug commands, Cutter
|
||||
commands, analysis-DB mutations, session teardowns, and ADB mutations) are
|
||||
gated by the plugin's `runtime` option (`"ask"` | `"allow"` | `"deny"`) on
|
||||
`rea` and `rea-runtime`, and are always denied on `rea-analyst` and
|
||||
`rea-auditor`.
|
||||
- Starting an interactive debugger or Frida session (`start_frida_session`,
|
||||
`start_gdb_session`) is classified by REA as session setup
|
||||
(`destructiveHint: false`), while subsequent code-injection or
|
||||
debug-execution tools (`load_frida_script`, `resume_frida_session`,
|
||||
`instrument_with_frida`, `gdb_console`, `start_rizin_debug_session`,
|
||||
`rizin_debug_command`, `execute_rizin_command`, `cutter_command`) and
|
||||
teardown tools (`close_gdb_session`, `close_rizin_debug_session`,
|
||||
`close_binary`) carry runtime-effect gating.
|
||||
- If a call is blocked by client policy, do not repeat it unchanged. Client
|
||||
approval, shell PATH, registration, and engine availability each have
|
||||
different remedies; diagnose the observed blocker and resume the original
|
||||
task after repair.
|
||||
|
||||
## Code Mode
|
||||
## Code Mode and Direct Tool Routing
|
||||
|
||||
- Scripted model tool calls route natively in the default config: host tools
|
||||
execute as themselves (for example `read` executes as `read`, with real
|
||||
file bytes returned), not through Code Mode `execute`.
|
||||
- Code Mode `execute`/`search` is the fallback path, not the primary route
|
||||
for REA analysis tools. Use `search` to discover the exact tool path and
|
||||
signature first; never guess tool names or call an unadvertised tool.
|
||||
- Native host tools (`read`, `skill`, `subagent`, and the plugin's own
|
||||
`rea_readiness` tool) always have `codemode: false` and execute directly as
|
||||
native tool calls.
|
||||
- For REA MCP tools (`rea_<tool>`):
|
||||
- When `codemode: false` is configured (either via plugin option
|
||||
`{ "codemode": false }` or on `mcp.servers.rea` in `opencode.json`),
|
||||
every `rea_<tool>` is advertised directly in the model's native tool list.
|
||||
- Under OpenCode v2's default MCP Code Mode (`codemode: true`), REA MCP
|
||||
tools are grouped under the `tools.rea` namespace inside `execute`
|
||||
(`await tools.rea.<tool>(input)`). Primary REA entry-point tools
|
||||
(`analyze_javascript_application`, `binary_session`,
|
||||
`inspect_analysis_view`, `inspect_android_package`, `inspect_artifact`,
|
||||
`inspect_binary_layout`, `inspect_managed_artifact`, `list_unknowns`,
|
||||
`open_binary`, `record_unknown`) are pinned in the Code Mode catalog
|
||||
summary; use `search` to discover the exact signature of any additional
|
||||
REA tool before calling it via `execute`. Never guess tool names or call
|
||||
an unadvertised tool.
|
||||
- Installing a skill supplies instructions only. It does not register the
|
||||
REA MCP server, install analysis engines, or add tools to an already
|
||||
running agent; prefer an existing `rea` CLI when one is installed.
|
||||
|
||||
## Compact-First Views and Large Tool Outputs
|
||||
|
||||
- Prefer `detail: "summary"` on first read or inventory calls, then page
|
||||
specific slices via `inspect_analysis_view` using the retained parent
|
||||
`evidence_id` rather than requesting full dumps upfront.
|
||||
- If OpenCode offloads a large MCP response to a `tool-output/tool_*` file,
|
||||
do not re-run the same analysis tool; page via `inspect_analysis_view` with
|
||||
the retained `evidence_id` or inspect the targeted keys from the saved
|
||||
output.
|
||||
|
||||
## Commands
|
||||
|
||||
- Commands under the `/rea:` namespace are host commands, not MCP tools.
|
||||
- Plugin slash commands (`/rea`, `/rea-doctor`, `/rea-report`) and REA MCP
|
||||
prompt commands under the `/rea:` namespace (`rea:investigate_feature`,
|
||||
`rea:compare_application_versions`, `rea:verify_reconstruction`,
|
||||
`rea:trace_crash`, `rea:audit_residual_unknowns`,
|
||||
`rea:prepare_bounded_process_capture`) are host commands, not MCP tools.
|
||||
Resolve them through the host's actual command list; do not invent command
|
||||
names beyond the `/rea:` pattern.
|
||||
names beyond the advertised list.
|
||||
- Keep command arguments caller-selected (paths, output directories, digests)
|
||||
and preserve them verbatim through the call.
|
||||
|
||||
## Recovery
|
||||
|
||||
- Run `rea_readiness` (or `/rea-doctor`) first when REA tools appear missing
|
||||
or disconnected; it reports live MCP status, total `reaToolCount`, plus
|
||||
`codemodeToolCount` and `directToolCount` without mutating configuration.
|
||||
- If REA tools are absent, read the connection-and-recovery guidance from the
|
||||
upstream skill's references, then continue useful supported work while the
|
||||
blocker stands.
|
||||
- Configuration repair starts with a read-only setup plan: show paths,
|
||||
backups, and skill replacement before applying authorized changes.
|
||||
- Configuration repair starts with a read-only setup plan
|
||||
(`rea doctor --client opencode --json` and
|
||||
`rea setup --client opencode --dry-run`): show paths, backups, and skill
|
||||
replacement before applying authorized changes.
|
||||
- Preserve existing approval and target-execution boundaries. REA setup never
|
||||
installs or upgrades unrelated software.
|
||||
- When the retained parent Evidence ID is unknown, page from a summary view
|
||||
|
||||
@@ -27,8 +27,11 @@ implementation and must be labeled as such.
|
||||
- Separate observed behavior from design choices in both the plan and the
|
||||
code. Observed behavior cites Evidence IDs; choices cite requirements,
|
||||
constraints, or judgment.
|
||||
- Use normal coding tools for the build. Close any opened native session
|
||||
with the appropriate teardown call when the investigation is complete.
|
||||
- Use normal coding tools for the build. Close any opened binary or
|
||||
interactive debugger/instrumentation session with the appropriate teardown
|
||||
call (`close_binary`, `close_frida_session`, `close_gdb_session`,
|
||||
`close_rizin_debug_session`, `unload_frida_script`) when the investigation
|
||||
is complete.
|
||||
- Preserve caller-selected inputs (paths, output directories, digests,
|
||||
metadata) verbatim into the build configuration.
|
||||
- Never widen the target or action beyond the declared fields when moving
|
||||
|
||||
+167
-16
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { RUNTIME_EFFECT_TOOLS } from "./effects.js"
|
||||
import { DEBUGGER_SESSION_TOOLS, RUNTIME_EFFECT_TOOLS, isDebuggerSessionTool, isRuntimeEffectTool } from "./effects.js"
|
||||
import type { ReaOptions, RuntimeOption } from "./options.js"
|
||||
import { sanitizeServerNamespace } from "./options.js"
|
||||
|
||||
@@ -16,12 +16,27 @@ export interface ParsedAgentFile {
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
export type ParsedAgent = ParsedAgentFile
|
||||
|
||||
export interface AgentPermissionRule {
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: RuntimeOption | "deny"
|
||||
}
|
||||
|
||||
export type PermissionRule = AgentPermissionRule
|
||||
|
||||
export interface PermissionEvaluationEventLike {
|
||||
readonly sessionID?: string
|
||||
readonly agent?: string
|
||||
readonly action: string
|
||||
readonly resource?: string
|
||||
readonly resources?: ReadonlyArray<string>
|
||||
readonly rules?: ReadonlyArray<{ action: string; resource: string; effect: string }>
|
||||
effect?: RuntimeOption | "deny"
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface AgentMutableLike {
|
||||
name: unknown
|
||||
description?: unknown
|
||||
@@ -37,6 +52,13 @@ interface AgentEditorLike {
|
||||
interface AgentCtxLike {
|
||||
agent: {
|
||||
transform(cb: (editor: AgentEditorLike) => void): unknown
|
||||
get?: (input: { agentID: string }) => Promise<unknown>
|
||||
}
|
||||
permission?: {
|
||||
hook(
|
||||
name: "evaluate",
|
||||
cb: (event: PermissionEvaluationEventLike) => Promise<unknown> | unknown,
|
||||
): unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,23 +153,126 @@ function agentMode(agentId: AgentId): "primary" | "subagent" {
|
||||
return agentId === "rea" ? "primary" : "subagent"
|
||||
}
|
||||
|
||||
/** Register the 4 shipped rea-* agents via ctx.agent.transform (update upserts). */
|
||||
function isKnownAgentId(id: string | undefined): id is AgentId {
|
||||
return typeof id === "string" && (AGENT_IDS as ReadonlyArray<string>).includes(id)
|
||||
}
|
||||
|
||||
function matchPattern(pattern: string, value: string): boolean {
|
||||
if (pattern === "*") return true
|
||||
if (!pattern.includes("*")) return value === pattern
|
||||
return new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the plugin's boundary policy for an OpenCode v2 `permission.evaluate`
|
||||
* hook event. In OpenCode v2, `ConfigAgentPlugin` runs in `post` (after external
|
||||
* plugins) and appends top-level `opencode.json` `permissions` (often
|
||||
* `{ action: "*", resource: "*", effect: "allow" }`) onto every agent's
|
||||
* permissions list, which would otherwise override `rea-*` specialist denies via
|
||||
* `findLast`. This evaluator restores the specialist boundary unless an explicit
|
||||
* non-wildcard per-agent rule (`action !== "*"`) was configured after the
|
||||
* plugin's rules.
|
||||
*/
|
||||
export function evaluateAgentPermission(
|
||||
event: PermissionEvaluationEventLike,
|
||||
options: ReaOptions,
|
||||
tailOverrides?: ReadonlyArray<{ action: string; resource: string; effect: string }>,
|
||||
): { effect: RuntimeOption | "deny" } | undefined {
|
||||
if (!isKnownAgentId(event.agent)) return undefined
|
||||
|
||||
const baselineRules = buildAgentPermissions(event.agent, options)
|
||||
const effectiveTail =
|
||||
tailOverrides !== undefined
|
||||
? tailOverrides
|
||||
: Array.isArray(event.rules) && event.rules.length > baselineRules.length
|
||||
? event.rules.slice(baselineRules.length)
|
||||
: []
|
||||
|
||||
// Respect explicit non-wildcard per-agent overrides configured in agents.<id>.permissions.
|
||||
const explicitOverride = [...effectiveTail]
|
||||
.reverse()
|
||||
.find((rule) => rule.action !== "*" && matchPattern(rule.action, event.action))
|
||||
if (
|
||||
explicitOverride &&
|
||||
(explicitOverride.effect === "allow" || explicitOverride.effect === "ask" || explicitOverride.effect === "deny")
|
||||
) {
|
||||
event.effect = explicitOverride.effect
|
||||
return { effect: explicitOverride.effect }
|
||||
}
|
||||
|
||||
const namespace = sanitizeServerNamespace(options.server)
|
||||
const prefix = `${namespace}_`
|
||||
const baseToolName = event.action.startsWith(prefix) ? event.action.slice(prefix.length) : ""
|
||||
const isGatedReaTool = baseToolName.length > 0 && isRuntimeEffectTool(baseToolName)
|
||||
const isDeniedHostTool = event.action === "edit" || event.action === "subagent" || event.action === "shell"
|
||||
|
||||
switch (event.agent) {
|
||||
case "rea-analyst":
|
||||
case "rea-auditor": {
|
||||
if (isDeniedHostTool || isGatedReaTool) {
|
||||
const role = event.agent === "rea-analyst" ? "static-only" : "read-only"
|
||||
event.effect = "deny"
|
||||
event.message = `Permission denied: ${event.agent} is ${role} and cannot execute "${event.action}".`
|
||||
return { effect: "deny" }
|
||||
}
|
||||
return event.effect ? { effect: event.effect } : undefined
|
||||
}
|
||||
case "rea-runtime": {
|
||||
if (isDeniedHostTool) {
|
||||
event.effect = "deny"
|
||||
event.message = `Permission denied: rea-runtime is an observation specialist and cannot execute "${event.action}".`
|
||||
return { effect: "deny" }
|
||||
}
|
||||
if (isGatedReaTool) {
|
||||
if (options.runtime === "deny") {
|
||||
event.effect = "deny"
|
||||
event.message = `Permission denied: runtime-effect tool "${event.action}" is disabled by plugin option runtime="deny".`
|
||||
return { effect: "deny" }
|
||||
}
|
||||
event.effect = options.runtime
|
||||
return { effect: options.runtime }
|
||||
}
|
||||
return event.effect ? { effect: event.effect } : undefined
|
||||
}
|
||||
case "rea": {
|
||||
if (isGatedReaTool) {
|
||||
if (options.runtime === "deny") {
|
||||
event.effect = "deny"
|
||||
event.message = `Permission denied: runtime-effect tool "${event.action}" is disabled by plugin option runtime="deny".`
|
||||
return { effect: "deny" }
|
||||
}
|
||||
event.effect = options.runtime
|
||||
return { effect: options.runtime }
|
||||
}
|
||||
return event.effect ? { effect: event.effect } : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the 4 shipped rea-* agents via a single ctx.agent.transform and optional permission hook. */
|
||||
export async function registerAgents(ctx: AgentCtxLike, options: ReaOptions): Promise<void> {
|
||||
const dir = resolveAgentsDir()
|
||||
for (const id of AGENT_IDS) {
|
||||
const path = join(dir, `${id}.md`)
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, "utf8")
|
||||
} catch {
|
||||
throw new Error(`missing agent file for agent "${id}" at ${path}`)
|
||||
}
|
||||
const parsed = parseAgentFile(raw, id)
|
||||
const permissions = buildAgentPermissions(id, options)
|
||||
await ctx.agent.transform((editor) => {
|
||||
// update() upserts (prior-art agent.ts): pushes append after the host
|
||||
// defaults and after earlier transforms, so config agents.<id> rules
|
||||
// registered later still win via last-match evaluation.
|
||||
const loaded = await Promise.all(
|
||||
AGENT_IDS.map(async (id) => {
|
||||
const path = join(dir, `${id}.md`)
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, "utf8")
|
||||
} catch {
|
||||
throw new Error(`missing agent file for agent "${id}" at ${path}`)
|
||||
}
|
||||
const parsed = parseAgentFile(raw, id)
|
||||
const permissions = buildAgentPermissions(id, options)
|
||||
return { id, parsed, permissions }
|
||||
}),
|
||||
)
|
||||
|
||||
const baselineLengths = new Map<AgentId, number>()
|
||||
await ctx.agent.transform((editor) => {
|
||||
// update() upserts (prior-art agent.ts): pushes append after the host
|
||||
// defaults and after earlier transforms, so config agents.<id> rules
|
||||
// registered later still win via last-match evaluation.
|
||||
for (const { id, parsed, permissions } of loaded) {
|
||||
editor.update(id, (agent) => {
|
||||
agent.name = id
|
||||
agent.description = parsed.description
|
||||
@@ -156,10 +281,36 @@ export async function registerAgents(ctx: AgentCtxLike, options: ReaOptions): Pr
|
||||
for (const rule of permissions) {
|
||||
agent.permissions.push({ ...rule })
|
||||
}
|
||||
baselineLengths.set(id, agent.permissions.length)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (typeof ctx.permission?.hook === "function") {
|
||||
await ctx.permission.hook("evaluate", async (event) => {
|
||||
if (!isKnownAgentId(event.agent)) return
|
||||
let tailOverrides: Array<{ action: string; resource: string; effect: string }> | undefined
|
||||
if (typeof ctx.agent.get === "function") {
|
||||
try {
|
||||
const res = (await ctx.agent.get({ agentID: event.agent })) as any
|
||||
const data = res?.data ?? res
|
||||
const perms: Array<{ action: string; resource: string; effect: string }> = Array.isArray(data?.permissions)
|
||||
? data.permissions
|
||||
: []
|
||||
const baseline = baselineLengths.get(event.agent) ?? 0
|
||||
if (perms.length > baseline) {
|
||||
tailOverrides = perms.slice(baseline)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort lookup; fall back to undefined tailOverrides.
|
||||
}
|
||||
}
|
||||
evaluateAgentPermission(event, options, tailOverrides)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { DEBUGGER_SESSION_TOOLS, isDebuggerSessionTool }
|
||||
|
||||
/** Convention alias: the module exposes one `register` alongside `registerAgents`. */
|
||||
export const register = registerAgents
|
||||
+17
-13
@@ -1,4 +1,5 @@
|
||||
import type { ReaOptions } from "./options.js"
|
||||
import { sanitizeServerNamespace } from "./options.js"
|
||||
import { READINESS_TOOL_NAME } from "./readiness.js"
|
||||
|
||||
export const COMMAND_IDS = ["rea", "rea-doctor", "rea-report"] as const
|
||||
@@ -65,36 +66,39 @@ function appendArgs(template: string, args: string): string {
|
||||
return `${template}\n\n${args}`
|
||||
}
|
||||
|
||||
function reaInvestigationText(args: string): string {
|
||||
function reaInvestigationText(args: string, options: ReaOptions): string {
|
||||
const namespace = sanitizeServerNamespace(options.server)
|
||||
const template = [
|
||||
"You are now acting as the rea reverse-engineering investigator agent.",
|
||||
"Run a staged investigation following the rea-investigation skill:",
|
||||
"scope the target, gather observations with evidence, then infer only from cited evidence.",
|
||||
"scope the target, gather observations with evidence (preferring detail: \"summary\" first and paging via inspect_analysis_view with parent_evidence_id),",
|
||||
`then infer only from cited evidence using the ${namespace} MCP tool surface.`,
|
||||
"Keep a strict observation-vs-inference discipline: every claim cites its evidence,",
|
||||
"and unknowns stay explicit until verified by a tool.",
|
||||
].join(" ")
|
||||
return appendArgs(template, args)
|
||||
}
|
||||
|
||||
function reaDoctorText(readinessTool: string): string {
|
||||
function reaDoctorText(readinessTool: string, options: ReaOptions): string {
|
||||
return [
|
||||
`Run the REA readiness check by calling the ${readinessTool} tool.`,
|
||||
`Report each readiness item and act on the returned remediation:`,
|
||||
`Run the REA readiness check for server "${options.server}" by calling the ${readinessTool} tool.`,
|
||||
"Report each readiness item (server status, total/codemode/direct tool counts, upstream skill availability, version) and act on the returned remediation:",
|
||||
"apply the safe fixes you can, and list the remaining follow-ups with owners.",
|
||||
].join(" ")
|
||||
}
|
||||
|
||||
function reaReportText(): string {
|
||||
return [
|
||||
"Produce the REA evidence report for this session: organize observations vs",
|
||||
"inferences vs unknowns, cite every claim with its Evidence ID, and flag",
|
||||
function reaReportText(args: string): string {
|
||||
const template = [
|
||||
"Produce the REA evidence report for this session following the rea-evidence-report skill: organize observations vs",
|
||||
"inferences vs unknowns, cite every claim with its Evidence ID, preserve null/unknown states without coercing them, and flag",
|
||||
"residual unknowns explicitly. When the report is complete, delegate a check",
|
||||
"to the rea-auditor agent for an independent audit pass.",
|
||||
].join(" ")
|
||||
return appendArgs(template, args)
|
||||
}
|
||||
|
||||
/** Register the 3 rea slash commands via ctx.command.transform. */
|
||||
export async function registerCommands(ctx: CommandCtxLike, _options: ReaOptions): Promise<void> {
|
||||
export async function registerCommands(ctx: CommandCtxLike, options: ReaOptions): Promise<void> {
|
||||
const readinessTool = READINESS_TOOL_NAME
|
||||
await ctx.command.transform((editor) => {
|
||||
editor.add({
|
||||
@@ -105,7 +109,7 @@ export async function registerCommands(ctx: CommandCtxLike, _options: ReaOptions
|
||||
await ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: reaInvestigationText(userText(input)),
|
||||
text: reaInvestigationText(userText(input), options),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
},
|
||||
@@ -117,7 +121,7 @@ export async function registerCommands(ctx: CommandCtxLike, _options: ReaOptions
|
||||
await ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: reaDoctorText(readinessTool),
|
||||
text: reaDoctorText(readinessTool, options),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
},
|
||||
@@ -129,7 +133,7 @@ export async function registerCommands(ctx: CommandCtxLike, _options: ReaOptions
|
||||
await ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: reaReportText(),
|
||||
text: reaReportText(userText(input)),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
},
|
||||
|
||||
+37
-9
@@ -1,15 +1,14 @@
|
||||
// T06 tool-effect classification for runtime gating.
|
||||
//
|
||||
// Source: prior-art TOOL_EFFECTS destructive formula
|
||||
// (review/rea/src/contracts/toolEffects.ts: destructive = mayDiscardData
|
||||
// Source: canonical TOOL_EFFECTS destructive formula
|
||||
// (rea/src/contracts/toolEffects.ts: destructive = mayDiscardData
|
||||
// || executesForeignCode || mutatesDevice) cross-checked against the live
|
||||
// rea-agents@6.4.0 tools/list (170 tools). The 7 live destructiveHint=true
|
||||
// tools are a subset of this 21-tool list because the installed server
|
||||
// under-flags attached-device and analyst-metadata mutations relative to the
|
||||
// audited contract. Live openWorldHint (72 tools, mostly read-mostly
|
||||
// launch-process/network tools such as list_adb_devices) is deliberately NOT
|
||||
// a listing requirement: this module covers the
|
||||
// data-loss/device/foreign-code axis only. Live-model behaviour unverified.
|
||||
// rea-agents@6.5.0 tools/list (207 tools, 31 destructiveHint=true tools).
|
||||
// Live openWorldHint (launchesProcess || accessesNetwork) is deliberately NOT
|
||||
// a listing requirement: RUNTIME_EFFECT_TOOLS covers the
|
||||
// data-loss/device/foreign-code axis only. Non-destructive debugger session
|
||||
// lifecycle tools (DEBUGGER_SESSION_TOOLS) are exported alongside for
|
||||
// specialist agent boundary enforcement. Live-model behaviour unverified.
|
||||
export type EffectCategory = "executes-foreign-code" | "mutates-device" | "may-discard-data"
|
||||
|
||||
export interface RuntimeEffect {
|
||||
@@ -25,11 +24,20 @@ export const RUNTIME_EFFECT_TOOLS: ReadonlyArray<RuntimeEffect> = [
|
||||
{ name: "capture_native_ui_scenario", categories: ["executes-foreign-code", "may-discard-data"], reason: "Drives native application code the server does not own and may overwrite prior capture state" },
|
||||
{ name: "capture_process_scenario", categories: ["executes-foreign-code"], reason: "Runs the caller-selected executable, executing target code the server does not own" },
|
||||
{ name: "close_binary", categories: ["may-discard-data"], reason: "Clears retained session records for the closed binary" },
|
||||
{ name: "close_gdb_session", categories: ["executes-foreign-code"], reason: "Detaches a live inferior with a verified durable TTY output sink, which may resume target execution" },
|
||||
{ name: "close_rizin_debug_session", categories: ["executes-foreign-code"], reason: "Detaches a live inferior with a verified TTY output sink, allowing target execution to continue" },
|
||||
{ name: "cutter_command", categories: ["executes-foreign-code"], reason: "Runs an arbitrary Rizin command in a live Cutter session that may execute plugin or target effects" },
|
||||
{ name: "execute_rizin_command", categories: ["executes-foreign-code", "may-discard-data"], reason: "Executes a Rizin CLI command with plugins enabled that can run foreign code or mutate files" },
|
||||
{ name: "export_evidence_bundle", categories: ["may-discard-data"], reason: "Replaces the destination file with the exported evidence bundle" },
|
||||
{ name: "gdb_console", categories: ["executes-foreign-code"], reason: "Runs an unrestricted GDB CLI command via MI that can execute target code, shell commands, or mutate state" },
|
||||
{ name: "install_adb_package", categories: ["mutates-device"], reason: "Installs a package on the attached Android device, changing device packages" },
|
||||
{ name: "instrument_with_frida", categories: ["executes-foreign-code"], reason: "Runs a caller-supplied Frida script inside the selected process, executing foreign code in the target" },
|
||||
{ name: "load_frida_script", categories: ["executes-foreign-code"], reason: "Loads caller-supplied JavaScript into a Frida session, executing code inside the target process" },
|
||||
{ name: "observe_native_calls", categories: ["executes-foreign-code"], reason: "Drives the caller-owned process that executes target code the server does not own" },
|
||||
{ name: "observe_web_execution", categories: ["may-discard-data"], reason: "Replaces live page state during observed web execution" },
|
||||
{ name: "push_adb_file", categories: ["mutates-device"], reason: "Writes a file to the attached Android device, changing device files" },
|
||||
{ name: "resume_frida_session", categories: ["executes-foreign-code"], reason: "Resumes a paused Frida spawn session, allowing the target process to execute" },
|
||||
{ name: "rizin_debug_command", categories: ["executes-foreign-code", "may-discard-data"], reason: "Executes an unrestricted Rizin debugger command that can run target code or discard buffered output" },
|
||||
{ name: "set_address_name", categories: ["may-discard-data"], reason: "Overwrites the analyst address name, discarding the prior name" },
|
||||
{ name: "set_addresses_names", categories: ["may-discard-data"], reason: "Overwrites analyst address names in bulk, discarding prior names" },
|
||||
{ name: "set_bookmark", categories: ["may-discard-data"], reason: "Overwrites the analyst bookmark at the address, discarding the prior bookmark" },
|
||||
@@ -37,11 +45,31 @@ export const RUNTIME_EFFECT_TOOLS: ReadonlyArray<RuntimeEffect> = [
|
||||
{ name: "set_inline_comment", categories: ["may-discard-data"], reason: "Overwrites the analyst inline comment, discarding the prior comment" },
|
||||
{ name: "start_adb_activity", categories: ["mutates-device"], reason: "Starts an activity on the attached Android device, changing running apps" },
|
||||
{ name: "start_adb_app", categories: ["mutates-device"], reason: "Starts an app on the attached Android device, changing running apps" },
|
||||
{ name: "start_rizin_debug_session", categories: ["executes-foreign-code"], reason: "Starts a persistent Rizin debugger session on the target binary" },
|
||||
{ name: "stop_adb_app", categories: ["mutates-device"], reason: "Stops an app on the attached Android device, changing running apps" },
|
||||
{ name: "uninstall_adb_package", categories: ["mutates-device"], reason: "Uninstalls a package from the attached Android device, changing device packages" },
|
||||
{ name: "unset_bookmark", categories: ["may-discard-data"], reason: "Clears the analyst bookmark, discarding the retained bookmark" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Debugger/instrumentation session lifecycle tools whose MCP annotations carry
|
||||
* destructiveHint=false (e.g. spawned target stays paused until resume), but
|
||||
* still attach/spawn debugger sessions and must not run under static-only or
|
||||
* read-only specialist agents.
|
||||
*/
|
||||
export const DEBUGGER_SESSION_TOOLS = [
|
||||
"close_frida_session",
|
||||
"start_frida_session",
|
||||
"start_gdb_session",
|
||||
"unload_frida_script",
|
||||
] as const
|
||||
|
||||
export type DebuggerSessionToolName = (typeof DEBUGGER_SESSION_TOOLS)[number]
|
||||
|
||||
export function isRuntimeEffectTool(name: string): boolean {
|
||||
return RUNTIME_EFFECT_TOOLS.some((entry) => entry.name === name)
|
||||
}
|
||||
|
||||
export function isDebuggerSessionTool(name: string): boolean {
|
||||
return (DEBUGGER_SESSION_TOOLS as ReadonlyArray<string>).includes(name)
|
||||
}
|
||||
@@ -5,6 +5,62 @@ import { parseOptions } from "./options.js"
|
||||
import { registerReadiness } from "./readiness.js"
|
||||
import { registerSkills } from "./skills.js"
|
||||
|
||||
export {
|
||||
AGENT_IDS,
|
||||
buildAgentPermissions,
|
||||
evaluateAgentPermission,
|
||||
parseAgentFile,
|
||||
registerAgents,
|
||||
resolveAgentsDir,
|
||||
type AgentId,
|
||||
type ParsedAgent,
|
||||
type PermissionRule,
|
||||
} from "./agents.js"
|
||||
export {
|
||||
COMMAND_IDS,
|
||||
REA_PROMPT_COMMANDS,
|
||||
registerCommands,
|
||||
type CommandId,
|
||||
} from "./commands.js"
|
||||
export {
|
||||
DEBUGGER_SESSION_TOOLS,
|
||||
RUNTIME_EFFECT_TOOLS,
|
||||
isDebuggerSessionTool,
|
||||
isRuntimeEffectTool,
|
||||
type DebuggerSessionToolName,
|
||||
type EffectCategory,
|
||||
type RuntimeEffectTool,
|
||||
} from "./effects.js"
|
||||
export {
|
||||
parseOptions,
|
||||
sanitizeServerNamespace,
|
||||
type PluginOptions,
|
||||
type RuntimePolicy,
|
||||
} from "./options.js"
|
||||
export {
|
||||
DOCTOR_CMD,
|
||||
PINNED_REA_TOOLS,
|
||||
READINESS_TOOL_NAME,
|
||||
REA_NAMESPACE_DESCRIPTION,
|
||||
ReadinessOutputSchema,
|
||||
SETUP_CMD,
|
||||
UPSTREAM_SKILL_ID,
|
||||
applyReaToolCatalogPolicy,
|
||||
countReaMcpTools,
|
||||
countReaToolsByMode,
|
||||
registerReadiness,
|
||||
type PinnedReaToolName,
|
||||
type ReadinessOutput,
|
||||
} from "./readiness.js"
|
||||
export {
|
||||
SKILL_IDS,
|
||||
parseSkillFile,
|
||||
registerSkills,
|
||||
resolveSkillsDir,
|
||||
type ParsedSkill,
|
||||
type SkillId,
|
||||
} from "./skills.js"
|
||||
|
||||
export default Plugin.define({
|
||||
id: "rea",
|
||||
setup: async (ctx) => {
|
||||
|
||||
+11
-2
@@ -3,9 +3,11 @@ export type RuntimeOption = "ask" | "allow" | "deny"
|
||||
export interface ReaOptions {
|
||||
readonly server: string
|
||||
readonly runtime: RuntimeOption
|
||||
readonly codemode?: boolean
|
||||
}
|
||||
|
||||
const RUNTIMES: ReadonlyArray<RuntimeOption> = ["ask", "allow", "deny"]
|
||||
const KNOWN_KEYS: ReadonlySet<string> = new Set(["server", "runtime", "codemode"])
|
||||
|
||||
/** Mirror the host MCP namespace derivation (prior-art tool/mcp.ts name()). */
|
||||
export function sanitizeServerNamespace(server: string): string {
|
||||
@@ -19,7 +21,7 @@ export function parseOptions(raw?: unknown): ReaOptions {
|
||||
}
|
||||
const input = raw as Record<string, unknown>
|
||||
for (const key of Object.keys(input)) {
|
||||
if (key !== "server" && key !== "runtime") {
|
||||
if (!KNOWN_KEYS.has(key)) {
|
||||
throw new Error(`unknown plugin option: "${key}"`)
|
||||
}
|
||||
}
|
||||
@@ -34,5 +36,12 @@ export function parseOptions(raw?: unknown): ReaOptions {
|
||||
if (typeof runtime !== "string" || !(RUNTIMES as ReadonlyArray<string>).includes(runtime)) {
|
||||
throw new Error(`invalid plugin option "runtime": expected one of "ask"|"allow"|"deny", got ${JSON.stringify(runtime) ?? typeof runtime}`)
|
||||
}
|
||||
return { server: server.trim(), runtime: runtime as RuntimeOption }
|
||||
if (input.codemode !== undefined && typeof input.codemode !== "boolean") {
|
||||
throw new Error(`invalid plugin option "codemode": expected a boolean, got ${typeof input.codemode}`)
|
||||
}
|
||||
return {
|
||||
server: server.trim(),
|
||||
runtime: runtime as RuntimeOption,
|
||||
...(input.codemode === undefined ? {} : { codemode: input.codemode }),
|
||||
}
|
||||
}
|
||||
+126
-11
@@ -9,18 +9,47 @@ export const UPSTREAM_SKILL_ID = "reverse-engineer-anything"
|
||||
export const DOCTOR_CMD = "rea doctor --client opencode --json"
|
||||
export const SETUP_CMD = "rea setup --client opencode --dry-run"
|
||||
|
||||
/**
|
||||
* Primary REA entry-point tools pinned in OpenCode v2's Code Mode catalog so
|
||||
* the 2,000-token inline summary always surfaces target-routing entry points
|
||||
* ahead of arbitrary short-signature helpers.
|
||||
*/
|
||||
export const PINNED_REA_TOOLS = [
|
||||
"analyze_javascript_application",
|
||||
"binary_session",
|
||||
"inspect_analysis_view",
|
||||
"inspect_android_package",
|
||||
"inspect_artifact",
|
||||
"inspect_binary_layout",
|
||||
"inspect_managed_artifact",
|
||||
"list_unknowns",
|
||||
"open_binary",
|
||||
"record_unknown",
|
||||
] as const
|
||||
|
||||
export type PinnedReaToolName = (typeof PINNED_REA_TOOLS)[number]
|
||||
|
||||
export const REA_NAMESPACE_DESCRIPTION =
|
||||
"REA reverse-engineering analysis, artifact inspection, version comparison, and runtime observation tools."
|
||||
|
||||
export interface ReadinessOutput {
|
||||
readonly server: string
|
||||
readonly status: string
|
||||
readonly statusError: string | null
|
||||
readonly reaToolCount: number
|
||||
readonly codemodeToolCount: number
|
||||
readonly directToolCount: number
|
||||
readonly upstreamSkillAvailable: boolean
|
||||
readonly version: string
|
||||
readonly remediation: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
interface ToolEditorLike {
|
||||
export interface ToolEditorLike {
|
||||
add(tool: any): void
|
||||
list?: () => Iterable<any> | ReadonlyArray<any>
|
||||
update?: (id: string, fn: (tool: any) => void) => void
|
||||
namespace?: (ns: { name: string; description?: string }) => void
|
||||
describe?: (ns: string, description: string) => void
|
||||
}
|
||||
|
||||
interface ReadinessCtxLike {
|
||||
@@ -36,18 +65,21 @@ interface ReadinessCtxLike {
|
||||
}
|
||||
}
|
||||
|
||||
const ReadinessInputSchema = {
|
||||
export const ReadinessInputSchema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
} as const
|
||||
const ReadinessOutputSchema = {
|
||||
|
||||
export const ReadinessOutputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
server: { type: "string" },
|
||||
status: { type: "string" },
|
||||
statusError: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
reaToolCount: { type: "number" },
|
||||
codemodeToolCount: { type: "number" },
|
||||
directToolCount: { type: "number" },
|
||||
upstreamSkillAvailable: { type: "boolean" },
|
||||
version: { type: "string" },
|
||||
remediation: { type: "array", items: { type: "string" } },
|
||||
@@ -57,6 +89,8 @@ const ReadinessOutputSchema = {
|
||||
"status",
|
||||
"statusError",
|
||||
"reaToolCount",
|
||||
"codemodeToolCount",
|
||||
"directToolCount",
|
||||
"upstreamSkillAvailable",
|
||||
"version",
|
||||
"remediation",
|
||||
@@ -119,7 +153,13 @@ function extractMcpStatus(mcpListResult: unknown, server: string): { status: str
|
||||
return { status: "missing", statusError: null }
|
||||
}
|
||||
|
||||
function countReaTools(toolListResult: unknown, server: string): number {
|
||||
export interface ReaToolCounts {
|
||||
readonly reaToolCount: number
|
||||
readonly codemodeToolCount: number
|
||||
readonly directToolCount: number
|
||||
}
|
||||
|
||||
export function countReaToolsByMode(toolListResult: unknown, server: string): ReaToolCounts {
|
||||
// Effective names carry the `<sanitized-server>_` prefix (default server is "rea", so
|
||||
// this is the rea_ count). Entries expose `id` and/or `name`; check both.
|
||||
// The plugin's own readiness tool matches the default prefix, so exclude it:
|
||||
@@ -130,16 +170,28 @@ function countReaTools(toolListResult: unknown, server: string): number {
|
||||
: Array.isArray((toolListResult as { data?: unknown } | null)?.data)
|
||||
? ((toolListResult as { data: Array<any> }).data)
|
||||
: []
|
||||
let count = 0
|
||||
let reaToolCount = 0
|
||||
let codemodeToolCount = 0
|
||||
let directToolCount = 0
|
||||
for (const entry of entries) {
|
||||
const id = typeof entry?.id === "string" ? (entry.id as string) : ""
|
||||
const name = typeof entry?.name === "string" ? (entry.name as string) : ""
|
||||
if (id === READINESS_TOOL_NAME || name === READINESS_TOOL_NAME) continue
|
||||
if (id.startsWith(prefix) || name.startsWith(prefix)) count++
|
||||
if (id.startsWith(prefix) || name.startsWith(prefix)) {
|
||||
reaToolCount++
|
||||
if (entry?.options?.codemode === false) directToolCount++
|
||||
else codemodeToolCount++
|
||||
}
|
||||
}
|
||||
return count
|
||||
return { reaToolCount, codemodeToolCount, directToolCount }
|
||||
}
|
||||
|
||||
export function countReaMcpTools(toolListResult: unknown, server: string): number {
|
||||
return countReaToolsByMode(toolListResult, server).reaToolCount
|
||||
}
|
||||
|
||||
export const countReaTools = countReaMcpTools
|
||||
|
||||
function isUpstreamSkillAvailable(skillListResult: unknown): boolean {
|
||||
const data = unwrapData(skillListResult)
|
||||
if (Array.isArray(data)) {
|
||||
@@ -151,13 +203,71 @@ function isUpstreamSkillAvailable(skillListResult: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Register the read-only `rea_readiness` native tool (no writes, no config mutation). */
|
||||
function entriesFromEditorList(raw: unknown): Array<any> {
|
||||
if (Array.isArray(raw)) return raw
|
||||
if (raw instanceof Map) return [...raw.values()]
|
||||
if (raw && typeof (raw as Iterable<any>)[Symbol.iterator] === "function") {
|
||||
return [...(raw as Iterable<any>)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the REA namespace description, pin primary entry-point tools in
|
||||
* OpenCode v2's Code Mode catalog, and apply any explicit `options.codemode`
|
||||
* override via `ctx.tool.transform` (never touching `ctx.mcp.transform`).
|
||||
*/
|
||||
export function applyReaToolCatalogPolicy(tools: ToolEditorLike, options: ReaOptions): void {
|
||||
const namespace = sanitizeServerNamespace(options.server)
|
||||
if (typeof tools.namespace === "function") {
|
||||
tools.namespace({
|
||||
name: namespace,
|
||||
description: REA_NAMESPACE_DESCRIPTION,
|
||||
})
|
||||
}
|
||||
if (typeof tools.describe === "function") {
|
||||
tools.describe(namespace, REA_NAMESPACE_DESCRIPTION)
|
||||
}
|
||||
if (typeof tools.update === "function") {
|
||||
const pinnedIds = new Set(PINNED_REA_TOOLS.map((name) => `${namespace}_${name}`))
|
||||
for (const id of pinnedIds) {
|
||||
tools.update(id, (tool) => {
|
||||
tool.options = {
|
||||
...(tool.options ?? {}),
|
||||
pinned: true,
|
||||
...(options.codemode === undefined ? {} : { codemode: options.codemode }),
|
||||
}
|
||||
})
|
||||
}
|
||||
if (options.codemode !== undefined && typeof tools.list === "function") {
|
||||
const prefix = `${namespace}_`
|
||||
for (const entry of entriesFromEditorList(tools.list())) {
|
||||
const id =
|
||||
typeof entry?.id === "string"
|
||||
? (entry.id as string)
|
||||
: typeof entry?.name === "string"
|
||||
? (entry.name as string)
|
||||
: ""
|
||||
if (!id.startsWith(prefix) || id === READINESS_TOOL_NAME || pinnedIds.has(id)) continue
|
||||
tools.update(id, (tool) => {
|
||||
tool.options = {
|
||||
...(tool.options ?? {}),
|
||||
codemode: options.codemode,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the read-only `rea_readiness` native tool and REA tool catalog policy (no writes, no MCP config mutation). */
|
||||
export async function registerReadiness(ctx: ReadinessCtxLike, options: ReaOptions): Promise<void> {
|
||||
await ctx.tool.transform((tools) => {
|
||||
applyReaToolCatalogPolicy(tools, options)
|
||||
tools.add({
|
||||
name: READINESS_TOOL_NAME,
|
||||
description:
|
||||
"Report REA MCP readiness: live server status, rea_ tool count, upstream skill availability, and remediation.",
|
||||
"Report REA MCP readiness: live server status, rea_ tool count (including Code Mode vs direct native counts), upstream skill availability, and remediation.",
|
||||
input: ReadinessInputSchema,
|
||||
output: ReadinessOutputSchema,
|
||||
options: { codemode: false },
|
||||
@@ -169,7 +279,8 @@ export async function registerReadiness(ctx: ReadinessCtxLike, options: ReaOptio
|
||||
resolvePluginVersion(),
|
||||
])
|
||||
const { status, statusError } = extractMcpStatus(mcpRes, options.server)
|
||||
const reaToolCount = countReaTools(toolRes, options.server)
|
||||
const counts = countReaToolsByMode(toolRes, options.server)
|
||||
const { reaToolCount, codemodeToolCount, directToolCount } = counts
|
||||
const upstreamSkillAvailable = isUpstreamSkillAvailable(skillRes)
|
||||
const remediation = buildRemediation()
|
||||
const output: ReadinessOutput = {
|
||||
@@ -177,16 +288,20 @@ export async function registerReadiness(ctx: ReadinessCtxLike, options: ReaOptio
|
||||
status,
|
||||
statusError,
|
||||
reaToolCount,
|
||||
codemodeToolCount,
|
||||
directToolCount,
|
||||
upstreamSkillAvailable,
|
||||
version,
|
||||
remediation: [...remediation],
|
||||
}
|
||||
const skillText = upstreamSkillAvailable ? "upstream skill available" : "upstream skill missing"
|
||||
const toolPrefix = `${sanitizeServerNamespace(options.server)}_`
|
||||
const modeSummary =
|
||||
reaToolCount > 0 ? ` (${codemodeToolCount} codemode, ${directToolCount} direct)` : ""
|
||||
const content =
|
||||
`rea_readiness: server "${options.server}" status "${status}"` +
|
||||
(statusError ? ` (${statusError})` : "") +
|
||||
`, ${reaToolCount} ${toolPrefix} tool(s), ${skillText}, version ${version}.` +
|
||||
`, ${reaToolCount} ${toolPrefix} tool(s)${modeSummary}, ${skillText}, version ${version}.` +
|
||||
` Remediation: ${remediation.join(" ; ")}`
|
||||
return { output, content }
|
||||
},
|
||||
|
||||
+21
-13
@@ -87,22 +87,30 @@ export function parseSkillFile(content: string, skillId = "unknown"): ParsedSkil
|
||||
return { name, description, body }
|
||||
}
|
||||
|
||||
/** Register the 4 shipped rea-* skills via ctx.skill.transform. */
|
||||
/** Register the 4 shipped rea-* skills via a single batched ctx.skill.transform. */
|
||||
export async function registerSkills(ctx: SkillCtxLike, _options: ReaOptions): Promise<void> {
|
||||
const dir = resolveSkillsDir()
|
||||
for (const id of SKILL_IDS) {
|
||||
const path = join(dir, id, "SKILL.md")
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, "utf8")
|
||||
} catch {
|
||||
throw new Error(`missing SKILL.md for skill "${id}" at ${path}`)
|
||||
}
|
||||
const parsed = parseSkillFile(raw, id)
|
||||
await ctx.skill.transform((editor) => {
|
||||
const loaded = await Promise.all(
|
||||
SKILL_IDS.map(async (id) => {
|
||||
const path = join(dir, id, "SKILL.md")
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, "utf8")
|
||||
} catch {
|
||||
throw new Error(`missing SKILL.md for skill "${id}" at ${path}`)
|
||||
}
|
||||
const parsed = parseSkillFile(raw, id)
|
||||
return { id, path, parsed }
|
||||
}),
|
||||
)
|
||||
await ctx.skill.transform((editor) => {
|
||||
for (const { id, path, parsed } of loaded) {
|
||||
// content = body after frontmatter, path = absolute SKILL.md path so
|
||||
// sibling references resolve via dirname (prior-art skill-file.ts shape).
|
||||
editor.add({ id, name: parsed.name, description: parsed.description, path, content: parsed.body })
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Convention alias: the module exposes one `register` alongside `registerSkills`. */
|
||||
export const register = registerSkills
|
||||
@@ -125,9 +125,13 @@ describe("T08 readiness via real host", () => {
|
||||
expect(VALID_STATUSES as ReadonlyArray<string>).toContain(output.status)
|
||||
expect(output.statusError === null || typeof output.statusError === "string").toBe(true)
|
||||
expect(typeof output.reaToolCount).toBe("number")
|
||||
expect(typeof output.codemodeToolCount).toBe("number")
|
||||
expect(typeof output.directToolCount).toBe("number")
|
||||
// MCP-only count excluding the plugin's own rea_readiness: the hermetic
|
||||
// env registers no MCP server, so this is 0 even though status may vary.
|
||||
expect(output.reaToolCount).toBe(0)
|
||||
expect(output.codemodeToolCount).toBe(0)
|
||||
expect(output.directToolCount).toBe(0)
|
||||
expect(typeof output.upstreamSkillAvailable).toBe("boolean")
|
||||
expect(output.version).toBe(await packageVersion())
|
||||
expect(JSON.stringify(output.remediation)).toContain(DOCTOR_CMD)
|
||||
@@ -155,6 +159,8 @@ describe("T08 readiness via real host", () => {
|
||||
expect(output.statusError).toBeNull()
|
||||
// MCP-only count: rea_readiness (self) excluded, so only rea_other counts.
|
||||
expect(output.reaToolCount).toBe(1)
|
||||
expect(output.codemodeToolCount).toBe(1)
|
||||
expect(output.directToolCount).toBe(0)
|
||||
expect(output.upstreamSkillAvailable).toBe(true)
|
||||
expect(output.version).toBe(await packageVersion())
|
||||
expect(JSON.stringify(output.remediation)).toContain(DOCTOR_CMD)
|
||||
@@ -190,6 +196,8 @@ describe("T08 readiness via real host", () => {
|
||||
expect(JSON.stringify(output.remediation)).toContain(DOCTOR_CMD)
|
||||
expect(JSON.stringify(output.remediation)).toContain(SETUP_CMD)
|
||||
expect(typeof output.reaToolCount).toBe("number")
|
||||
expect(typeof output.codemodeToolCount).toBe("number")
|
||||
expect(typeof output.directToolCount).toBe("number")
|
||||
expect(typeof output.upstreamSkillAvailable).toBe("boolean")
|
||||
expect(typeof output.version).toBe("string")
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isAbsolute } from "node:path"
|
||||
import {
|
||||
AGENT_IDS,
|
||||
buildAgentPermissions,
|
||||
evaluateAgentPermission,
|
||||
parseAgentFile,
|
||||
registerAgents,
|
||||
resolveAgentsDir,
|
||||
@@ -30,6 +31,7 @@ interface CapturedAgent {
|
||||
|
||||
function makeCaptureCtx() {
|
||||
const store = new Map<string, CapturedAgent>()
|
||||
const hooks: Array<{ type: string; handler: (input: any) => Promise<any> }> = []
|
||||
const ctx = {
|
||||
agent: {
|
||||
transform: async (cb: (editor: { update: (id: string, fn: (a: CapturedAgent) => void) => void }) => void) => {
|
||||
@@ -47,8 +49,14 @@ function makeCaptureCtx() {
|
||||
return { dispose: async () => {} }
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
hook: async (type: string, handler: (input: any) => Promise<any>) => {
|
||||
hooks.push({ type, handler })
|
||||
return { dispose: async () => {} }
|
||||
},
|
||||
},
|
||||
}
|
||||
return { ctx, store }
|
||||
return { ctx, store, hooks }
|
||||
}
|
||||
|
||||
/** Mirror of prior-art Permission.evaluate: LAST matching rule wins, wildcards both fields. */
|
||||
@@ -71,14 +79,16 @@ describe("T07 agents: ids and registration", () => {
|
||||
expect([...AGENT_IDS]).toEqual([...EXPECTED_IDS])
|
||||
})
|
||||
|
||||
test("registerAgents upserts exactly the 4 agents with modes (rea primary, rest subagent)", async () => {
|
||||
const { ctx, store } = makeCaptureCtx()
|
||||
test("registerAgents upserts exactly the 4 agents with modes (rea primary, rest subagent) and registers permission evaluate hook", async () => {
|
||||
const { ctx, store, hooks } = makeCaptureCtx()
|
||||
await registerAgents(ctx as any, { server: "rea", runtime: "ask" })
|
||||
expect([...store.keys()].sort()).toEqual([...EXPECTED_IDS].sort())
|
||||
expect(store.get("rea")?.mode).toBe("primary")
|
||||
for (const id of ["rea-analyst", "rea-runtime", "rea-auditor"] as const) {
|
||||
expect(store.get(id)?.mode).toBe("subagent")
|
||||
}
|
||||
expect(hooks.length).toBe(1)
|
||||
expect(hooks[0]?.type).toBe("evaluate")
|
||||
})
|
||||
|
||||
test("each agent has id-matching name, non-empty description/system; agents dir resolves and files exist", async () => {
|
||||
@@ -111,7 +121,7 @@ describe("T07 agents: ids and registration", () => {
|
||||
|
||||
describe("T07 agents: permission matrix (agent x runtime)", () => {
|
||||
for (const runtime of ["ask", "allow", "deny"] as const) {
|
||||
test(`rea gets 21 tool rules with effect "${runtime}" and no edit/subagent/shell denies`, () => {
|
||||
test(`rea gets 31 tool rules with effect "${runtime}" and no edit/subagent/shell denies`, () => {
|
||||
const rules = buildAgentPermissions("rea", { server: "rea", runtime })
|
||||
expect(rules.length).toBe(RUNTIME_EFFECT_TOOLS.length)
|
||||
expect(rules.some((r) => r.action === "edit" || r.action === "subagent" || r.action === "shell")).toBe(false)
|
||||
@@ -120,7 +130,7 @@ describe("T07 agents: permission matrix (agent x runtime)", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test(`rea-analyst is static-only under runtime "${runtime}" (edit+subagent+shell deny, 21 tool denies)`, () => {
|
||||
test(`rea-analyst is static-only under runtime "${runtime}" (edit+subagent+shell deny, 31 tool denies)`, () => {
|
||||
const rules = buildAgentPermissions("rea-analyst", { server: "rea", runtime })
|
||||
expect(rules.length).toBe(3 + RUNTIME_EFFECT_TOOLS.length)
|
||||
expect(rules[0]).toEqual({ action: "edit", resource: "*", effect: "deny" })
|
||||
@@ -196,9 +206,6 @@ describe("T07 agents: rule ordering and override semantics", () => {
|
||||
})
|
||||
|
||||
test("user-config override wins via last-write-wins (config rule appended after plugin rules)", () => {
|
||||
// Host fact: external plugins activate before config plugins, so a
|
||||
// user-config agents.<id> rule lands after plugin rules and wins via
|
||||
// findLast. Proved here through direct editor semantics.
|
||||
const pluginRules = buildAgentPermissions("rea-analyst", { server: "rea", runtime: "ask" })
|
||||
expect(evaluate("rea_capture_process_scenario", "*", pluginRules)).toBe("deny")
|
||||
const withOverride: CapturedRule[] = [
|
||||
@@ -208,6 +215,71 @@ describe("T07 agents: rule ordering and override semantics", () => {
|
||||
expect(evaluate("rea_capture_process_scenario", "*", withOverride)).toBe("allow")
|
||||
})
|
||||
|
||||
test("evaluateAgentPermission enforces agent boundaries under global wildcard allow while honoring explicit overrides", () => {
|
||||
const options = { server: "rea", runtime: "ask" as const }
|
||||
const auditorRules = [
|
||||
...buildAgentPermissions("rea-auditor", options),
|
||||
// Simulates OpenCode v2 ConfigAgentPlugin appending global permissions: allow-all
|
||||
{ action: "*", resource: "*", effect: "allow" as const },
|
||||
]
|
||||
expect(
|
||||
evaluateAgentPermission(
|
||||
{
|
||||
action: "edit",
|
||||
resource: "/tmp/file.txt",
|
||||
agent: "rea-auditor",
|
||||
rules: auditorRules,
|
||||
},
|
||||
options,
|
||||
),
|
||||
).toEqual({ effect: "deny" })
|
||||
|
||||
const runtimeRules = [
|
||||
...buildAgentPermissions("rea-runtime", options),
|
||||
{ action: "*", resource: "*", effect: "allow" as const },
|
||||
]
|
||||
expect(
|
||||
evaluateAgentPermission(
|
||||
{
|
||||
action: "rea_capture_process_scenario",
|
||||
resource: "*",
|
||||
agent: "rea-runtime",
|
||||
rules: runtimeRules,
|
||||
},
|
||||
options,
|
||||
),
|
||||
).toEqual({ effect: "ask" })
|
||||
|
||||
const analystWithExplicitOverride = [
|
||||
...buildAgentPermissions("rea-analyst", options),
|
||||
{ action: "rea_capture_process_scenario", resource: "*", effect: "allow" as const },
|
||||
{ action: "*", resource: "*", effect: "allow" as const },
|
||||
]
|
||||
expect(
|
||||
evaluateAgentPermission(
|
||||
{
|
||||
action: "rea_capture_process_scenario",
|
||||
resource: "*",
|
||||
agent: "rea-analyst",
|
||||
rules: analystWithExplicitOverride,
|
||||
},
|
||||
options,
|
||||
),
|
||||
).toEqual({ effect: "allow" })
|
||||
|
||||
expect(
|
||||
evaluateAgentPermission(
|
||||
{
|
||||
action: "edit",
|
||||
resource: "/tmp/file.txt",
|
||||
agent: "coder",
|
||||
rules: [{ action: "*", resource: "*", effect: "allow" }],
|
||||
},
|
||||
options,
|
||||
),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("registered rea agent permissions equal the matrix builder output", async () => {
|
||||
for (const runtime of ["ask", "allow", "deny"] as const) {
|
||||
const { ctx, store } = makeCaptureCtx()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { COMMAND_IDS, REA_PROMPT_COMMANDS, registerCommands } from "../../src/commands.js"
|
||||
|
||||
describe("T09 unit: slash commands registration and execution", () => {
|
||||
test("COMMAND_IDS and REA_PROMPT_COMMANDS do not overlap", () => {
|
||||
expect([...COMMAND_IDS]).toEqual(["rea", "rea-doctor", "rea-report"])
|
||||
expect(REA_PROMPT_COMMANDS).toHaveLength(6)
|
||||
for (const cmd of COMMAND_IDS) {
|
||||
expect(cmd).not.toContain(":")
|
||||
expect((REA_PROMPT_COMMANDS as ReadonlyArray<string>).includes(cmd)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test("registerCommands wires /rea, /rea-doctor, and /rea-report with server context and user args", async () => {
|
||||
const added: any[] = []
|
||||
const sessionCalls: Array<{ method: string; input: any }> = []
|
||||
const ctx: any = {
|
||||
command: {
|
||||
transform: async (cb: (editor: any) => void) => {
|
||||
cb({ add: (d: any) => void added.push(d) })
|
||||
return { dispose: async () => {} }
|
||||
},
|
||||
},
|
||||
session: {
|
||||
switchAgent: async (input: any) => void sessionCalls.push({ method: "switchAgent", input }),
|
||||
prompt: async (input: any) => void sessionCalls.push({ method: "prompt", input }),
|
||||
},
|
||||
}
|
||||
|
||||
await registerCommands(ctx, { server: "custom.rea", runtime: "ask" })
|
||||
expect(added.map((d) => d.name)).toEqual(["rea", "rea-doctor", "rea-report"])
|
||||
|
||||
// /rea switches to primary rea agent and passes target prompt + sanitized server namespace
|
||||
const reaCmd = added.find((d) => d.name === "rea")
|
||||
await reaCmd.execute({ sessionID: "s1", prompt: { text: "inspect auth flow" }, delivery: "immediate" })
|
||||
expect(sessionCalls[0]).toEqual({ method: "switchAgent", input: { sessionID: "s1", agent: "rea" } })
|
||||
expect(sessionCalls[1]?.method).toBe("prompt")
|
||||
expect(sessionCalls[1]?.input?.text).toContain("inspect auth flow")
|
||||
expect(sessionCalls[1]?.input?.text).toContain("custom_rea")
|
||||
expect(sessionCalls[1]?.input?.text).toContain("inspect_analysis_view")
|
||||
|
||||
// /rea-doctor keeps constant rea_readiness tool name while naming configured server
|
||||
sessionCalls.length = 0
|
||||
const doctorCmd = added.find((d) => d.name === "rea-doctor")
|
||||
await doctorCmd.execute({ sessionID: "s2", prompt: { text: "" }, delivery: undefined })
|
||||
expect(sessionCalls.some((c) => c.method === "switchAgent")).toBe(false)
|
||||
expect(sessionCalls[0]?.input?.text).toContain("rea_readiness")
|
||||
expect(sessionCalls[0]?.input?.text).not.toContain("custom.rea_readiness")
|
||||
expect(sessionCalls[0]?.input?.text).toContain("custom.rea")
|
||||
|
||||
// /rea-report includes user scope when provided and delegates audit to rea-auditor
|
||||
sessionCalls.length = 0
|
||||
const reportCmd = added.find((d) => d.name === "rea-report")
|
||||
await reportCmd.execute({ sessionID: "s3", prompt: { text: "focus on TLS pinning" }, delivery: undefined })
|
||||
expect(sessionCalls.some((c) => c.method === "switchAgent")).toBe(false)
|
||||
expect(sessionCalls[0]?.input?.text).toContain("rea-auditor")
|
||||
expect(sessionCalls[0]?.input?.text).toContain("focus on TLS pinning")
|
||||
})
|
||||
})
|
||||
+20
-4
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { existsSync } from "node:fs"
|
||||
import { DEBUGGER_SESSION_TOOLS, RUNTIME_EFFECT_TOOLS } from "../../src/effects.js"
|
||||
import { PINNED_REA_TOOLS } from "../../src/readiness.js"
|
||||
|
||||
/**
|
||||
* T15 docs guard: README.md must state the release facts accurately, and
|
||||
@@ -13,11 +15,13 @@ describe("T15 docs guard (README accuracy)", () => {
|
||||
version: string
|
||||
exports: unknown
|
||||
files: string[]
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
// Hard-assert known release values (fail loudly on drift).
|
||||
expect(pkg.name).toBe("@heretek-ai/opencode-rea")
|
||||
expect(pkg.version).toBe("0.1.0")
|
||||
expect(pkg.version).toBe("0.1.1")
|
||||
expect(pkg.exports).toEqual({ ".": "./dist/index.js" })
|
||||
expect(pkg.devDependencies?.["rea-agents"]).toBe("6.5.0")
|
||||
for (const entry of ["dist", "skills", "agents", "index.js"]) {
|
||||
expect(pkg.files).toContain(entry)
|
||||
}
|
||||
@@ -25,14 +29,15 @@ describe("T15 docs guard (README accuracy)", () => {
|
||||
const readme = await Bun.file("README.md").text()
|
||||
// README must mention the same identity (not invented values).
|
||||
expect(readme).toContain("opencode-rea")
|
||||
expect(readme).toContain("0.1.0")
|
||||
expect(readme).toContain("0.1.2")
|
||||
expect(readme).toContain("6.5.0")
|
||||
expect(readme).toContain("./dist/index.js")
|
||||
for (const entry of ["dist", "skills", "agents", "index.js"]) {
|
||||
expect(readme).toContain(entry)
|
||||
}
|
||||
})
|
||||
|
||||
test("every agent README names exists on disk", async () => {
|
||||
test("every agent README names exists on disk and README lists all 31 runtime-effect tools", async () => {
|
||||
const readme = await Bun.file("README.md").text()
|
||||
const agentIds = ["rea", "rea-analyst", "rea-runtime", "rea-auditor"]
|
||||
for (const id of agentIds) {
|
||||
@@ -44,6 +49,15 @@ describe("T15 docs guard (README accuracy)", () => {
|
||||
for (const id of agentIds) {
|
||||
expect(agentsSrc).toContain(`"${id}"`)
|
||||
}
|
||||
for (const tool of RUNTIME_EFFECT_TOOLS) {
|
||||
expect(readme).toContain(`rea_${tool.name}`)
|
||||
}
|
||||
for (const sessionTool of DEBUGGER_SESSION_TOOLS) {
|
||||
expect(readme).toContain(sessionTool)
|
||||
}
|
||||
for (const pinnedTool of PINNED_REA_TOOLS) {
|
||||
expect(readme).toContain(pinnedTool)
|
||||
}
|
||||
})
|
||||
|
||||
test("every skill README names exists on disk", async () => {
|
||||
@@ -87,7 +101,7 @@ describe("T15 docs guard (README accuracy)", () => {
|
||||
const optionsSrc = await Bun.file("src/options.ts").text()
|
||||
expect(optionsSrc).toContain('"rea"') // default server
|
||||
expect(optionsSrc).toContain('"ask"') // default runtime
|
||||
for (const token of ["server", "runtime", "ask", "allow", "deny"]) {
|
||||
for (const token of ["server", "runtime", "codemode", "ask", "allow", "deny"]) {
|
||||
expect(readme).toContain(token)
|
||||
}
|
||||
// Unknown keys / wrong types fail setup.
|
||||
@@ -97,6 +111,8 @@ describe("T15 docs guard (README accuracy)", () => {
|
||||
const readinessSrc = await Bun.file("src/readiness.ts").text()
|
||||
expect(readinessSrc).toContain("rea_readiness")
|
||||
expect(readme).toContain("rea_readiness")
|
||||
expect(readme).toContain("codemodeToolCount")
|
||||
expect(readme).toContain("directToolCount")
|
||||
// Remediation is advice text only.
|
||||
expect(readme).toContain("rea doctor --client opencode --json")
|
||||
expect(readme).toContain("rea setup --client opencode --dry-run")
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RUNTIME_EFFECT_TOOLS, isRuntimeEffectTool } from "../../src/effects.js"
|
||||
import {
|
||||
DEBUGGER_SESSION_TOOLS,
|
||||
RUNTIME_EFFECT_TOOLS,
|
||||
isDebuggerSessionTool,
|
||||
isRuntimeEffectTool,
|
||||
} from "../../src/effects.js"
|
||||
|
||||
describe("T06 tool-effect classification", () => {
|
||||
test("list is non-empty with exactly 21 entries", () => {
|
||||
expect(RUNTIME_EFFECT_TOOLS.length).toBe(21)
|
||||
test("list is non-empty with exactly 31 entries ([email protected] destructiveHint===true)", () => {
|
||||
expect(RUNTIME_EFFECT_TOOLS.length).toBe(31)
|
||||
})
|
||||
|
||||
test("names are unique and sorted", () => {
|
||||
@@ -12,6 +17,24 @@ describe("T06 tool-effect classification", () => {
|
||||
expect([...names].sort()).toEqual(names)
|
||||
})
|
||||
|
||||
test("includes all 10 [email protected] debugger/instrumentation/rizin/cutter tools", () => {
|
||||
const names = new Set(RUNTIME_EFFECT_TOOLS.map((e) => e.name))
|
||||
for (const addedIn650 of [
|
||||
"close_gdb_session",
|
||||
"close_rizin_debug_session",
|
||||
"cutter_command",
|
||||
"execute_rizin_command",
|
||||
"gdb_console",
|
||||
"instrument_with_frida",
|
||||
"load_frida_script",
|
||||
"resume_frida_session",
|
||||
"rizin_debug_command",
|
||||
"start_rizin_debug_session",
|
||||
]) {
|
||||
expect(names.has(addedIn650)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("every entry has at least one valid category and a non-empty one-line reason", () => {
|
||||
const valid = ["executes-foreign-code", "mutates-device", "may-discard-data"]
|
||||
for (const entry of RUNTIME_EFFECT_TOOLS) {
|
||||
@@ -30,5 +53,21 @@ describe("T06 tool-effect classification", () => {
|
||||
}
|
||||
expect(isRuntimeEffectTool("binary_session")).toBe(false)
|
||||
expect(isRuntimeEffectTool("list_adb_devices")).toBe(false)
|
||||
expect(isRuntimeEffectTool("start_frida_session")).toBe(false)
|
||||
expect(isRuntimeEffectTool("start_gdb_session")).toBe(false)
|
||||
})
|
||||
|
||||
test("DEBUGGER_SESSION_TOOLS and isDebuggerSessionTool cover non-destructive session lifecycle tools", () => {
|
||||
expect([...DEBUGGER_SESSION_TOOLS]).toEqual([
|
||||
"close_frida_session",
|
||||
"start_frida_session",
|
||||
"start_gdb_session",
|
||||
"unload_frida_script",
|
||||
])
|
||||
for (const name of DEBUGGER_SESSION_TOOLS) {
|
||||
expect(isDebuggerSessionTool(name)).toBe(true)
|
||||
expect(isRuntimeEffectTool(name)).toBe(false)
|
||||
}
|
||||
expect(isDebuggerSessionTool("gdb_console")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,12 @@ describe("T04 options boundary: parseOptions", () => {
|
||||
expect(parseOptions({ server: "custom", runtime: "allow" })).toEqual({ server: "custom", runtime: "allow" })
|
||||
expect(parseOptions({ runtime: "deny" })).toEqual({ server: "rea", runtime: "deny" })
|
||||
expect(parseOptions({ server: "x" })).toEqual({ server: "x", runtime: "ask" })
|
||||
expect(parseOptions({ codemode: false })).toEqual({ server: "rea", runtime: "ask", codemode: false })
|
||||
expect(parseOptions({ server: "rea", runtime: "allow", codemode: true })).toEqual({
|
||||
server: "rea",
|
||||
runtime: "allow",
|
||||
codemode: true,
|
||||
})
|
||||
expect(parseOptions({})).toEqual({ server: "rea", runtime: "ask" })
|
||||
})
|
||||
|
||||
@@ -62,6 +68,19 @@ describe("T04 options boundary: parseOptions", () => {
|
||||
expect(String((error as Error).message)).toContain("runtime")
|
||||
})
|
||||
|
||||
test("codemode non-boolean fails with error naming codemode", () => {
|
||||
for (const bad of ["false", 0, "true", null, {}]) {
|
||||
let error: unknown
|
||||
try {
|
||||
parseOptions({ codemode: bad })
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(String((error as Error).message)).toContain("codemode")
|
||||
}
|
||||
})
|
||||
|
||||
test("non-object options fail", () => {
|
||||
for (const bad of [42, "rea", true, []]) {
|
||||
let error: unknown
|
||||
@@ -100,9 +119,6 @@ describe("T04 options boundary: parseOptions", () => {
|
||||
|
||||
describe("T04 options boundary: setup wiring", () => {
|
||||
test("setup validates ctx.options: bad options throw, defaults pass", async () => {
|
||||
// Plugin.define preserves setup, so invoke it directly with a fake ctx.
|
||||
// Setup also registers skills (T05), agents (T07), readiness (T08) and
|
||||
// commands (T09), so stub skill/agent/tool/command.transform as no-ops.
|
||||
const setup = (plugin as unknown as { setup: (ctx: unknown) => Promise<unknown> }).setup
|
||||
expect(typeof setup).toBe("function")
|
||||
const withSkill = (options: unknown) => ({
|
||||
@@ -114,6 +130,7 @@ describe("T04 options boundary: setup wiring", () => {
|
||||
})
|
||||
await setup(withSkill(undefined))
|
||||
await setup(withSkill({}))
|
||||
await setup(withSkill({ codemode: false }))
|
||||
let error: unknown
|
||||
try {
|
||||
await setup(withSkill({ bogus: true }))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
DOCTOR_CMD,
|
||||
PINNED_REA_TOOLS,
|
||||
READINESS_TOOL_NAME,
|
||||
REA_NAMESPACE_DESCRIPTION,
|
||||
SETUP_CMD,
|
||||
UPSTREAM_SKILL_ID,
|
||||
applyReaToolCatalogPolicy,
|
||||
countReaMcpTools,
|
||||
countReaToolsByMode,
|
||||
registerReadiness,
|
||||
} from "../../src/readiness.js"
|
||||
|
||||
describe("T08 unit: readiness & Code Mode tool catalog policy", () => {
|
||||
test("countReaMcpTools and countReaToolsByMode count MCP tools while excluding rea_readiness", () => {
|
||||
const tools = [
|
||||
{ id: READINESS_TOOL_NAME, name: READINESS_TOOL_NAME, options: { codemode: false } },
|
||||
{ id: "rea_binary_session", name: "rea_binary_session", options: { codemode: true, pinned: true } },
|
||||
{ id: "rea_inspect_artifact", name: "rea_inspect_artifact", options: {} },
|
||||
{ id: "rea_read_native_function", name: "rea_read_native_function", options: { codemode: false } },
|
||||
{ id: "read", name: "read", options: { codemode: false } },
|
||||
]
|
||||
expect(countReaMcpTools(tools, "rea")).toBe(3)
|
||||
expect(countReaToolsByMode(tools, "rea")).toEqual({
|
||||
reaToolCount: 3,
|
||||
codemodeToolCount: 2,
|
||||
directToolCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test("applyReaToolCatalogPolicy describes namespace, pins primary entry-point tools, and applies optional codemode", () => {
|
||||
const described = new Map<string, string>()
|
||||
const entries = new Map<string, any>([
|
||||
["rea_binary_session", { id: "rea_binary_session", options: { codemode: true } }],
|
||||
["rea_analyze_javascript_application", { id: "rea_analyze_javascript_application", options: { codemode: true } }],
|
||||
["rea_read_native_function", { id: "rea_read_native_function", options: { codemode: true } }],
|
||||
["read", { id: "read", options: { codemode: false } }],
|
||||
])
|
||||
const editor = {
|
||||
namespace: (ns: { name: string; description?: string }) => void described.set(ns.name, ns.description ?? ""),
|
||||
list: () => [...entries.values()],
|
||||
update: (id: string, fn: (t: any) => void) => {
|
||||
const entry = entries.get(id)
|
||||
if (entry) fn(entry)
|
||||
},
|
||||
}
|
||||
|
||||
// Default (codemode omitted): pins entry-point tools without flipping codemode
|
||||
applyReaToolCatalogPolicy(editor, { server: "rea", runtime: "ask" })
|
||||
expect(described.get("rea")).toBe(REA_NAMESPACE_DESCRIPTION)
|
||||
expect(entries.get("rea_binary_session")?.options?.pinned).toBe(true)
|
||||
expect(entries.get("rea_analyze_javascript_application")?.options?.pinned).toBe(true)
|
||||
expect(entries.get("rea_read_native_function")?.options?.pinned).toBeUndefined()
|
||||
expect(entries.get("rea_read_native_function")?.options?.codemode).toBe(true)
|
||||
|
||||
// Explicit codemode: false flips all rea_* MCP tools to direct mode
|
||||
applyReaToolCatalogPolicy(editor, { server: "rea", runtime: "ask", codemode: false })
|
||||
expect(entries.get("rea_binary_session")?.options?.codemode).toBe(false)
|
||||
expect(entries.get("rea_read_native_function")?.options?.codemode).toBe(false)
|
||||
expect(entries.get("read")?.options?.codemode).toBe(false)
|
||||
expect(PINNED_REA_TOOLS.length).toBe(10)
|
||||
})
|
||||
|
||||
test("registerReadiness reports codemodeToolCount, directToolCount, status, and remediation", async () => {
|
||||
const added: any[] = []
|
||||
const described = new Map<string, string>()
|
||||
const toolEntries = new Map<string, any>([
|
||||
["rea_binary_session", { id: "rea_binary_session", name: "rea_binary_session", options: { codemode: true } }],
|
||||
["rea_inspect_artifact", { id: "rea_inspect_artifact", name: "rea_inspect_artifact", options: { codemode: true } }],
|
||||
])
|
||||
const ctx: any = {
|
||||
tool: {
|
||||
transform: async (cb: (editor: any) => void) => {
|
||||
cb({
|
||||
add: (t: any) => {
|
||||
added.push(t)
|
||||
toolEntries.set(t.name, t)
|
||||
},
|
||||
namespace: (ns: { name: string; description?: string }) => void described.set(ns.name, ns.description ?? ""),
|
||||
list: () => [...toolEntries.values()],
|
||||
update: (id: string, fn: (t: any) => void) => {
|
||||
const entry = toolEntries.get(id)
|
||||
if (entry) fn(entry)
|
||||
},
|
||||
})
|
||||
return { dispose: async () => {} }
|
||||
},
|
||||
list: async () => [...toolEntries.values()],
|
||||
},
|
||||
mcp: {
|
||||
list: async () => ({
|
||||
location: { directory: process.cwd() },
|
||||
data: [{ name: "rea", status: { status: "connected" } }],
|
||||
}),
|
||||
},
|
||||
skill: {
|
||||
list: async () => ({
|
||||
location: { directory: process.cwd() },
|
||||
data: [{ id: UPSTREAM_SKILL_ID }],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
await registerReadiness(ctx, { server: "rea", runtime: "ask", codemode: false })
|
||||
expect(described.get("rea")).toBe(REA_NAMESPACE_DESCRIPTION)
|
||||
expect(toolEntries.get("rea_binary_session")?.options?.pinned).toBe(true)
|
||||
expect(toolEntries.get("rea_binary_session")?.options?.codemode).toBe(false)
|
||||
|
||||
const readinessTool = added.find((t) => t.name === READINESS_TOOL_NAME)
|
||||
expect(readinessTool).toBeDefined()
|
||||
expect(readinessTool.options?.codemode).toBe(false)
|
||||
|
||||
const res = await readinessTool.execute({}, {})
|
||||
expect(res.output.server).toBe("rea")
|
||||
expect(res.output.status).toBe("connected")
|
||||
expect(res.output.statusError).toBeNull()
|
||||
expect(res.output.reaToolCount).toBe(2)
|
||||
expect(res.output.codemodeToolCount).toBe(0)
|
||||
expect(res.output.directToolCount).toBe(2)
|
||||
expect(res.output.upstreamSkillAvailable).toBe(true)
|
||||
expect(res.output.remediation).toEqual([DOCTOR_CMD, SETUP_CMD])
|
||||
expect(res.content).toContain("0 codemode, 2 direct")
|
||||
})
|
||||
})
|
||||
@@ -6,12 +6,12 @@ describe("T01 package scaffold", () => {
|
||||
expect(pkg.name).toBe("@heretek-ai/opencode-rea")
|
||||
expect(pkg.type).toBe("module")
|
||||
expect(pkg.exports).toEqual({ ".": "./dist/index.js" })
|
||||
expect(pkg.files).toEqual(expect.arrayContaining(["dist", "skills", "agents"]))
|
||||
expect(pkg.files).toEqual(expect.arrayContaining(["dist", "skills", "agents", "index.js"]))
|
||||
expect(pkg.peerDependencies?.["@opencode/plugin"]).toBe("^2")
|
||||
expect(pkg.devDependencies?.["@opencode/plugin"]).toBe("2.0.26")
|
||||
expect(pkg.devDependencies?.["@opencode/sdk"]).toBe("2.0.26")
|
||||
expect(pkg.devDependencies?.["@opencode/cli"]).toBe("2.0.26")
|
||||
expect(pkg.devDependencies?.["rea-agents"]).toBe("6.4.0")
|
||||
expect(pkg.devDependencies?.["rea-agents"]).toBe("6.5.0")
|
||||
expect(pkg.devDependencies?.["typescript"]).toBeDefined()
|
||||
expect(pkg.devDependencies?.["@types/bun"]).toBeDefined()
|
||||
expect(pkg.scripts?.check).toBeDefined()
|
||||
|
||||
Reference in new issue
Block a user