Skip to content
Draft
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
8 changes: 8 additions & 0 deletions packages/caprock/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "caprock",
"version": "0.1.0",
"description": "Routes Claude Code tool invocations through an ocap-kernel permission vat (POLA enforcement).",
"repository": "https://github.com/MetaMask/ocap-kernel",
"license": "MIT"
}
10 changes: 10 additions & 0 deletions packages/caprock/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

[Unreleased]: https://github.com/MetaMask/ocap-kernel/
15 changes: 15 additions & 0 deletions packages/caprock/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# `@ocap/caprock`

Claude Code plugin: routes tool invocations through an ocap-kernel permission vat (POLA enforcement)

## Installation

`yarn add @ocap/caprock`

or

`npm install @ocap/caprock`

## Contributing

This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme).
12 changes: 12 additions & 0 deletions packages/caprock/bin/harden-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* No-op harden shim for the hook process.
*
* The hook is not a vat — it must not run SES lockdown because full lockdown
* is incompatible with native tree-sitter bindings. @endo modules call
* harden() at module-evaluation time, so we install a benign identity
* function as the global before any @endo import evaluates.
*
* ESM evaluates modules depth-first in import order, so placing this as
* the first import in hook.ts guarantees it runs before @endo/promise-kit.
*/
(globalThis as { harden?: <T>(value: T) => T }).harden ??= (value) => value;
115 changes: 115 additions & 0 deletions packages/caprock/bin/hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* eslint-disable n/no-process-env */
import { execFile, spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

const execFileAsync = promisify(execFile);

const HOOK_BIN = fileURLToPath(
new URL('../dist/bin/hook.mjs', import.meta.url),
);
const PKG_DIR = fileURLToPath(new URL('..', import.meta.url));

/**
* Spawn hook.mjs with a JSON payload on stdin and collect all output.
*
* @param payload - The hook event payload to send.
* @param env - Extra environment variables.
* @param timeoutMs - Kill timeout in milliseconds.
* @returns stdout, stderr, and exit code.
*/
async function runHook(
payload: unknown,
env: NodeJS.ProcessEnv,
timeoutMs: number,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve, reject) => {
const child = spawn('node', [HOOK_BIN], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});

let stdout = '';
let stderr = '';

child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});

const timer = setTimeout(() => {
child.kill();
reject(new Error(`Hook timed out after ${timeoutMs}ms`));
}, timeoutMs);

child.on('close', (code) => {
clearTimeout(timer);
resolve({ stdout, stderr, exitCode: code ?? -1 });
});

child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});

child.stdin.write(JSON.stringify(payload));
child.stdin.end();
});
}

describe('hook binary', () => {
let ocapHome: string;

beforeAll(async () => {
await execFileAsync('yarn', ['build'], { cwd: PKG_DIR });
ocapHome = await mkdtemp(join(tmpdir(), 'caprock-hook-test-'));
}, 60_000);

afterAll(async () => {
await rm(ocapHome, { recursive: true, force: true });
});

it('loads without SES globals (SessionStart)', async () => {
const { stderr, exitCode } = await runHook(
{
hook_event_name: 'SessionStart',
session_id: 'hook-integration-test',
transcript_path: '/dev/null',
},
{ OCAP_HOME: ocapHome },
8_000,
);

expect(exitCode).toBe(0);
expect(stderr).not.toMatch(/harden is not defined/u);
expect(stderr).not.toMatch(/Cannot initialize @endo\/errors/u);
expect(stderr).not.toMatch(/missing globalThis\.assert/u);
}, 8_000);

it('loads without SES globals (PreToolUse)', async () => {
const { stdout, stderr, exitCode } = await runHook(
{
hook_event_name: 'PreToolUse',
session_id: 'hook-integration-test',
transcript_path: '/dev/null',
tool_name: 'Bash',
tool_input: { command: 'ls -la' },
},
{ OCAP_HOME: ocapHome },
8_000,
);

expect(exitCode).toBe(0);
expect(stderr).not.toMatch(/harden is not defined/u);
expect(stderr).not.toMatch(/Cannot initialize @endo\/errors/u);
expect(stderr).not.toMatch(/missing globalThis\.assert/u);
// With no daemon running the hook must not block — it passes through.
expect(stdout).toContain('"continue":true');
}, 8_000);
});
Loading
Loading