6 Commits
Author SHA1 Message Date
John Smith 3f8b729c3f chore(release): fix 0.1.2 release blockers
- package.json: restore scoped name @heretek-ai/opencode-rea (regression from merge 974c8dd)
- test/unit/docs.test.ts: pin expected version to 0.1.2
- src/index.ts: re-export real type names (RuntimeEffect, ReaOptions, RuntimeOption, ParsedSkillFile)
- test/unit/readiness.test.ts: add missing add() to ToolEditorLike fake

Verified: tsc check clean, build clean, unit 91/91, integration 19/19, e2e 8/8
2026-10-16 00:27:45 -04:00
BillyOutlast 6192bada3f Merge pull request #2 from Heretek-AI/integration/v0.1
feat: enhance REA plugin with Code Mode support and tool catalog policy
2026-10-16 00:11:19 -04:00
BillyOutlast 974c8dd85a Merge branch 'main' into integration/v0.1 2026-10-16 00:11:03 -04:00
John Smith 657a3aef1b feat: enhance REA plugin with Code Mode support and tool catalog policy
- Added `codemode` option to `ReaOptions` for flexible tool behavior.
- Updated `parseOptions` to validate and handle `codemode` correctly.
- Enhanced readiness reporting to include `codemodeToolCount` and `directToolCount`.
- Implemented `countReaToolsByMode` to categorize tools based on their mode.
- Introduced `applyReaToolCatalogPolicy` to manage tool registration and options.
- Registered new commands for REA, ensuring they operate within the context of the server.
- Updated tests to cover new functionality and ensure correctness of tool counts and options.
- Adjusted documentation to reflect changes in options and tool behavior.
2026-10-16 00:08:45 -04:00
John Smith f2aff44ea9 chore(release): bump to 0.1.1 to validate OIDC trusted publishing 2026-10-12 10:02:43 -04:00
John Smith d9dd1ebbb8 ci: OIDC trusted-publishing workflow (publish on release) 2026-10-12 09:58:01 -04:00
28 changed files with 1050 additions and 191 deletions

No files matched your search

+38
View File
@@ -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
+11 -4
View File
@@ -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.
+71 -38
View File
@@ -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
View File
@@ -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
+6 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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";
+3 -3
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@heretek-ai/opencode-rea",
"version": "0.1.0",
"description": "opencode-rea plugin",
"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": [
+6 -4
View File
@@ -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
+16 -9
View File
@@ -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.
+60 -18
View File
@@ -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
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+56
View File
@@ -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 RuntimeEffect,
} from "./effects.js"
export {
parseOptions,
sanitizeServerNamespace,
type ReaOptions,
type RuntimeOption,
} 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 ParsedSkillFile,
type SkillId,
} from "./skills.js"
export default Plugin.define({
id: "rea",
setup: async (ctx) => {
+11 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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")
})
+80 -8
View File
@@ -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()
+59
View File
@@ -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
View File
@@ -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.2")
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")
+42 -3
View File
@@ -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)
})
})
+20 -3
View File
@@ -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 }))
+126
View File
@@ -0,0 +1,126 @@
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 = {
add: (_tool: any) => undefined,
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")
})
})
+2 -2
View File
@@ -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()