Merge remote-tracking branch 'origin/wave/0.10.1-next' into land-security-1002

This commit is contained in:
CodeWhale Bot committed 2026-10-01 20:30:58 -07:00
commit 7f3fcb285c
17 files changed
+2471 -334

No files matched your search

+46 -1
View File
@@ -3,7 +3,8 @@
// `dist/` is committed so `cargo build` never needs Node or npm; CI rebuilds
// and fails on drift (`git diff --exit-code dist`).
import { build } from 'esbuild'
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -50,6 +51,50 @@ await build({
banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/protocol.ts; do not edit.' },
})
// Built-in host modules (tier 0): each `src/builtin/<id>.ts` is built to
// `dist/builtin/<id>.mjs`, the file the core activates under the owner id
// `host:<id>`, and `dist/builtin-modules.json` records the SHA-256 of every
// one. The Rust table `BUILTIN_MODULES` (src/extension_host/tier.rs) pins the
// same digests, and a Rust test fails when the two disagree. There are no
// built-in modules yet, so the file lists none and `dist/builtin/` is absent.
const builtinSource = join(here, 'src/builtin')
const builtinOut = join(outdir, 'builtin')
rmSync(builtinOut, { recursive: true, force: true }) // a removed module leaves nothing behind
const builtinIds = existsSync(builtinSource)
? readdirSync(builtinSource)
.filter((file) => file.endsWith('.ts'))
.map((file) => file.slice(0, -'.ts'.length))
.sort()
: []
const builtinDigests = {}
for (const id of builtinIds) {
// Mirrors `valid_module_id` in tier.rs: the id is also a file name.
if (!/^[a-z][a-z0-9-]{0,63}$/.test(id)) throw new Error(`src/builtin/${id}.ts is not a valid built-in module name`)
const outfile = join(builtinOut, `${id}.mjs`)
const built = await build({
entryPoints: [join(builtinSource, `${id}.ts`)],
outfile,
absWorkingDir: here,
metafile: true,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22.19',
sourcemap: false,
minify: false,
legalComments: 'none',
charset: 'utf8',
logLevel: 'warning',
banner: { js: `// codewhale built-in host module ${id} — generated by crates/tui/extension-host/build.mjs; do not edit.` },
})
// LICENSES.txt covers the host bundle only; a module that bundled a package
// would ship code with no notice.
if (Object.keys(built.metafile.inputs).some((input) => input.includes('node_modules/'))) {
throw new Error(`built-in module ${id} bundles a package from node_modules; add its licence notice to the build first`)
}
builtinDigests[id] = createHash('sha256').update(readFileSync(outfile)).digest('hex')
}
writeFileSync(join(outdir, 'builtin-modules.json'), JSON.stringify({ modules: builtinDigests }, null, 2) + '\n')
// Third-party notices (`dist/LICENSES.txt`), generated from what the bundler
// actually put in the host bundle: every package that contributed an input
+3
View File
@@ -0,0 +1,3 @@
{
"modules": {}
}
+40 -2
View File
@@ -4647,6 +4647,26 @@ var OwnedRegistrations = class {
}
};
// src/tier.ts
var HOST_OWNER_PREFIX = "host:";
var TIERS = ["plugin", "builtin"];
function parseTier(argv) {
let found;
for (const arg of argv) {
if (arg !== "--tier" && !arg.startsWith("--tier=")) continue;
const value = arg.startsWith("--tier=") ? arg.slice("--tier=".length) : "";
if (!TIERS.includes(value)) {
throw new Error(`unknown host tier ${JSON.stringify(value)} (expected --tier=plugin or --tier=builtin)`);
}
if (found !== void 0) throw new Error("the host tier was given more than once");
found = value;
}
return found ?? "plugin";
}
function ownerTier(ownerId) {
return ownerId.startsWith(HOST_OWNER_PREFIX) ? "builtin" : "plugin";
}
// src/shims/commands.ts
var COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u;
var NO_ATTACHMENTS = Object.freeze([]);
@@ -4793,8 +4813,9 @@ function isJson(value, depth = 0) {
}
}
var HostRoot = class {
constructor(rpc2) {
constructor(rpc2, tier) {
this.rpc = rpc2;
this.tier = tier;
const root = new Context();
this.root = root;
const host2 = this;
@@ -4858,6 +4879,7 @@ var HostRoot = class {
root.plugin(CommandsShim);
}
rpc;
tier;
root;
owners = /* @__PURE__ */ new Map();
toolRegistrations;
@@ -4890,6 +4912,13 @@ var HostRoot = class {
* included, are disposed and the owner forgotten (all-or-nothing).
*/
async activate(params) {
const wanted = ownerTier(params.owner.plugin_id);
if (wanted !== this.tier) {
throw new RpcError(
ErrorCode.InvalidParams,
`owner ${JSON.stringify(params.owner.plugin_id)} belongs to the ${wanted} tier, but this host serves the ${this.tier} tier`
);
}
const key = params.owner.owner_token;
const existing = this.owners.get(key);
if (existing) {
@@ -5096,6 +5125,15 @@ function pendingFibers(fiber) {
// src/main.ts
var HOST_VERSION = "0.1.0";
var TIER = (() => {
try {
return parseTier(process.argv.slice(2));
} catch (error) {
process.stderr.write(`codewhale-extension-host: ${error.message}
`);
return process.exit(64);
}
})();
var MEMORY_LIMIT_MIB = applyMemoryLimit();
var channelWrite = process.stdout.write.bind(process.stdout);
var stderrWrite = process.stderr.write.bind(process.stderr);
@@ -5156,7 +5194,7 @@ function shutdownNow(code) {
var rpc = new RpcPeer((message) => {
channelWrite(encodeFrame(message));
});
var host = new HostRoot(rpc);
var host = new HostRoot(rpc, TIER);
var initialized = false;
rpc.onRequest("host/initialize", (params) => {
if (params.protocol !== PROTOCOL_VERSION) {
+17 -3
View File
@@ -18,11 +18,25 @@ import { ErrorCode, FrameDecoder, encodeFrame, PROTOCOL_VERSION, type Message }
import { RpcError, RpcPeer } from './rpc.ts'
import { HostRoot, ownerStorage } from './root.ts'
import { RUNTIME, applyMemoryLimit, denyNativeCode } from './runtime.ts'
import { parseTier } from './tier.ts'
export const HOST_VERSION = '0.1.0'
// 0. The kernel memory limit the core asked for (macOS + Bun). This may
// re-execute the process in place, so it runs before anything else.
// 0a. Which trust tier this process serves (`--tier=plugin|builtin`). Anything
// else is refused before the host does any work. `process.exit` is still
// the real one here; step 2 replaces it.
const TIER = (() => {
try {
return parseTier(process.argv.slice(2))
} catch (error) {
process.stderr.write(`codewhale-extension-host: ${(error as Error).message}\n`)
return process.exit(64)
}
})()
// 0b. The kernel memory limit the core asked for (macOS + Bun). This may
// re-execute the process in place (argv, and so the tier, are kept), so it
// runs before anything else that has an effect.
const MEMORY_LIMIT_MIB = applyMemoryLimit()
// 1. Own the protocol channel; rebind every other stdout writer to stderr.
@@ -111,7 +125,7 @@ function shutdownNow(code: number) {
const rpc = new RpcPeer((message: Message) => {
channelWrite(encodeFrame(message))
})
const host = new HostRoot(rpc)
const host = new HostRoot(rpc, TIER)
let initialized = false
rpc.onRequest('host/initialize', (params: any) => {
+15 -1
View File
@@ -25,6 +25,7 @@ import {
import { RpcError, type RpcPeer } from './rpc.ts'
import { explainImportError } from './dsh/resolve-hooks.ts'
import { OwnedRegistrations } from './shims/owned.ts'
import { ownerTier, type HostTier } from './tier.ts'
import {
commandSpec,
defineCommandsService,
@@ -130,7 +131,11 @@ export class HostRoot {
private readonly toolRegistrations: OwnedRegistrations<OwnerRecord, LocalTool>
private readonly commandRegistrations: OwnedRegistrations<OwnerRecord, LocalCommand<OwnerRecord>>
constructor(private readonly rpc: RpcPeer) {
constructor(
private readonly rpc: RpcPeer,
/** The trust tier this process serves; it activates only owners of that tier. */
readonly tier: HostTier,
) {
const root: any = new Context()
this.root = root
const host = this
@@ -238,6 +243,15 @@ export class HostRoot {
* included, are disposed and the owner forgotten (all-or-nothing).
*/
async activate(params: ActivateParams): Promise<ActivateResult> {
// This process serves one tier. An owner of the other tier is refused
// outright, before anything of it is read or loaded.
const wanted = ownerTier(params.owner.plugin_id)
if (wanted !== this.tier) {
throw new RpcError(
ErrorCode.InvalidParams,
`owner ${JSON.stringify(params.owner.plugin_id)} belongs to the ${wanted} tier, but this host serves the ${this.tier} tier`,
)
}
const key = params.owner.owner_token
const existing = this.owners.get(key)
if (existing) {
+46
View File
@@ -0,0 +1,46 @@
/**
* Which trust tier this host process serves.
*
* The core starts one host process per tier from the same bundle and says
* which with `--tier=plugin|builtin`. A plugin-tier host runs reviewed
* third-party plugins, whose owner ids are the plugin ids the core's discovery
* builds (`<scope>/<hex>/<name>`); a builtin-tier host runs Codewhale's own
* host code, whose owner ids are `host:<module>`. The two id spaces cannot
* meet, and each process refuses an owner of the other tier
* (`HostRoot.activate`), so a core that mixed them up would be told so
* instead of running one tier's code in the other's process.
*
* Not a security boundary on its own (the OS sandbox and the separate
* processes are); the Rust core is the authority on what runs where.
*/
export type HostTier = 'plugin' | 'builtin'
/** Every tier-0 owner id starts with this. Mirrors `tier::HOST_OWNER_PREFIX` in Rust. */
export const HOST_OWNER_PREFIX = 'host:'
const TIERS: readonly string[] = ['plugin', 'builtin']
/**
* The tier named by `--tier=` in `argv` (the arguments after the script), or
* `plugin` when there is none: the least-privileged tier is the default for a
* host started by hand. An unknown value, a flag without a value, or a tier
* named twice throws, so the host refuses to start rather than guess.
*/
export function parseTier(argv: readonly string[]): HostTier {
let found: HostTier | undefined
for (const arg of argv) {
if (arg !== '--tier' && !arg.startsWith('--tier=')) continue
const value = arg.startsWith('--tier=') ? arg.slice('--tier='.length) : ''
if (!TIERS.includes(value)) {
throw new Error(`unknown host tier ${JSON.stringify(value)} (expected --tier=plugin or --tier=builtin)`)
}
if (found !== undefined) throw new Error('the host tier was given more than once')
found = value as HostTier
}
return found ?? 'plugin'
}
/** The tier an owner id belongs to. Total: the id decides. */
export function ownerTier(ownerId: string): HostTier {
return ownerId.startsWith(HOST_OWNER_PREFIX) ? 'builtin' : 'plugin'
}
+4 -2
View File
@@ -43,12 +43,14 @@ export const LIMITS = { max_frame: 32 * 1024 * 1024, max_inflight: 256, dispose_
/**
* Start a host. `admit(spec, owner)` decides `registry/register`: return a
* handle number or `{ refused }`. Registry traffic is recorded in `registry`.
* `tier` is the trust tier it serves (`--tier=`, after the bundle, as the Rust
* core passes it); `null` passes none, as a host started by hand would.
*/
export async function startHost({ admit, env, ownGroup = false } = {}) {
export async function startHost({ admit, env, ownGroup = false, tier = 'plugin' } = {}) {
const started = performance.now()
// `ownGroup` spawns the host as a process-group leader and tells it so, as
// the Rust core does on Unix.
const child = spawn(process.execPath, [...HOST_ARGS, BUNDLE], {
const child = spawn(process.execPath, [...HOST_ARGS, BUNDLE, ...(tier === null ? [] : [`--tier=${tier}`])], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...HOST_ENV, ...env, ...(ownGroup ? { CODEWHALE_HOST_PROCESS_GROUP: '1' } : {}) },
detached: ownGroup,
+64 -3
View File
@@ -1,14 +1,14 @@
// End-to-end tests of the committed host bundle against a fake core.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { execFileSync, spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { BUNDLE, FIXTURES, HOST_ARGS, IS_BUN, activate, owner, sha256File, startHost } from './harness.mjs'
import { encodeFrame } from '../dist/protocol.mjs'
import { BUNDLE, FIXTURES, HOST_ARGS, HOST_ENV, IS_BUN, activate, owner, sha256File, startHost } from './harness.mjs'
import { ErrorCode, encodeFrame } from '../dist/protocol.mjs'
// Budgets for a cold host start (the first host this file starts), gated in
// the first test. Measured 2026-09-30 on macOS arm64, 5 starts each: Node 26
@@ -789,3 +789,64 @@ test('a plugin is given its config, validated by its own Config schema, and each
const bare2 = await activate(host, 'plugin-context', entry)
assert.equal(bare2.result.status, 'ok')
})
// ---- Trust tiers: one host process per tier, told which with `--tier=`.
test('the host refuses to start on an unknown tier, a tier with no value or a tier named twice', async () => {
for (const args of [['--tier=root'], ['--tier='], ['--tier'], ['--tier=Plugin'], ['--tier=plugin', '--tier=builtin'], ['--tier=builtin', '--tier=builtin']]) {
const child = spawn(process.execPath, [...HOST_ARGS, BUNDLE, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...HOST_ENV },
})
let stderr = ''
let stdoutBytes = 0
child.stderr.on('data', (chunk) => { stderr += chunk })
child.stdout.on('data', (chunk) => { stdoutBytes += chunk.length })
const { code } = await new Promise((resolve) => child.on('exit', (code, signal) => resolve({ code, signal })))
assert.equal(code, 64, `${args.join(' ')}: ${stderr}`)
assert.match(stderr, /tier/, args.join(' '))
assert.equal(stdoutBytes, 0, `${args.join(' ')}: a host that refuses its tier never says hello`)
}
})
test('a plugin-tier host refuses a host: owner and a builtin-tier host refuses a plugin owner, before loading anything', async (t) => {
const entry = join(FIXTURES, 'ext-commands', 'index.mjs')
const request = (ref) => ({ owner: ref, plugin_name: 'ext-commands', entry: { path: entry, sha256: sha256File(entry) }, config: {} })
const refused = (tier) => (error) => {
assert.equal(error.code, ErrorCode.InvalidParams)
assert.match(error.message, new RegExp(`this host serves the ${tier} tier`))
return true
}
// No `--tier=` is the plugin tier, the least-privileged one.
for (const tier of ['plugin', null]) {
const host = await startHost({ tier })
t.after(() => host.stop())
await assert.rejects(host.call('ext/activate', request(owner('host:ext-commands'))), refused('plugin'))
assert.equal(host.registry.length, 0, 'nothing of the refused owner was loaded')
// The same fixture activates under a plugin id.
const { result } = await activate(host, 'ext-commands')
assert.equal(result.status, 'ok')
}
const builtin = await startHost({ tier: 'builtin' })
t.after(() => builtin.stop())
await assert.rejects(builtin.call('ext/activate', request(owner('user/0123456789ab/ext-commands'))), refused('builtin'))
await assert.rejects(builtin.call('ext/activate', request(owner('ext-commands'))), refused('builtin'))
assert.equal(builtin.registry.length, 0, 'nothing of the refused owners was loaded')
// A `host:` owner is what a builtin-tier host takes.
const ref = owner('host:ext-commands')
const result = await builtin.call('ext/activate', request(ref))
assert.equal(result.status, 'ok')
assert.ok(result.commands.includes('ext-echo'))
assert.deepEqual(await builtin.call('ext/deactivate', { owner: ref }), { disposed: true, leaked: [] })
})
test('the build records the digest of every built-in module, and only those', () => {
const dist = join(dirname(BUNDLE))
const manifest = JSON.parse(readFileSync(join(dist, 'builtin-modules.json'), 'utf8'))
const built = existsSync(join(dist, 'builtin')) ? readdirSync(join(dist, 'builtin')).sort() : []
assert.deepEqual(Object.keys(manifest.modules).map((id) => `${id}.mjs`).sort(), built)
for (const [id, digest] of Object.entries(manifest.modules)) {
assert.equal(sha256File(join(dist, 'builtin', `${id}.mjs`)), digest, id)
}
})
File diff suppressed because it is too large. Load diff
@@ -57,8 +57,8 @@ impl PluginConfig {
Self { value, hash }
}
/// What a plugin with no settings is given.
fn empty() -> Self {
/// What a plugin with no settings is given (and what a built-in module is).
pub(crate) fn empty() -> Self {
Self::new(Value::Object(serde_json::Map::new()))
}
}
+91 -26
View File
@@ -5,6 +5,11 @@
//! removes its tools from every later turn's registry at once, and
//! `HostToolSpec` re-checks liveness before each call. Handles are never
//! reused, so undoing one registration can never touch a newer one.
//!
//! One registry serves both trust tiers ([`super::tier`]): an owner records
//! which host process it lives in, `begin_owner` refuses an id the tier cannot
//! hold (`host:<module>` is tier 0's alone), and a host exit removes only its
//! own tier's owners and registrations.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
@@ -12,6 +17,7 @@ use std::sync::Arc;
use serde_json::Value;
use super::protocol::{OwnerRef, RegisterKind, RegisterParams};
use super::tier::HostTier;
use crate::plugins::types::PluginAuthority;
/// Largest accepted tool input schema, serialized.
@@ -58,8 +64,14 @@ pub enum OwnerState {
#[derive(Debug, Clone)]
pub struct OwnerEntry {
pub owner: OwnerRef,
/// The host process this owner lives in. Fixed at [`OwnerRegistry::begin_owner`],
/// which refuses an id the tier cannot hold.
pub tier: HostTier,
pub plugin_name: String,
pub authority: PluginAuthority,
/// The reviewed plugin authority (plugin tier). A built-in module has none:
/// what it is bound to is the source digest the Rust table pins, which is
/// its `content_hash`.
pub authority: Option<PluginAuthority>,
pub content_hash: String,
/// Digest of the plugin config this activation was given (empty until
/// [`OwnerRegistry::set_config_hash`]). A change of config is a different
@@ -148,6 +160,8 @@ impl std::fmt::Debug for InputValidator {
pub struct ToolRegistration {
pub handle: u64,
pub owner: OwnerRef,
/// The tier of `owner`'s host.
pub tier: HostTier,
pub plugin_name: String,
/// The reviewed bundle content hash of the owner that registered it: the
/// receipt its approval grants are bound to.
@@ -165,6 +179,8 @@ pub struct ToolRegistration {
pub struct CommandRegistration {
pub handle: u64,
pub owner: OwnerRef,
/// The tier of `owner`'s host.
pub tier: HostTier,
pub plugin_name: String,
/// The reviewed bundle content hash of the registering owner.
pub content_hash: String,
@@ -292,14 +308,33 @@ impl OwnerRegistry {
.extend(names.into_iter().map(str::to_ascii_lowercase));
}
/// Start a new activation for `plugin_id`, superseding any previous one.
/// Start a new activation for `plugin_id` on `tier`, superseding any
/// previous one. Refuses an id the tier cannot hold (a `host:` id on the
/// plugin tier, any other on the builtin tier) and an authority that does
/// not fit it: a plugin owner is bound to its reviewed plugin authority, a
/// built-in module to none (its pinned digest is `content_hash`).
pub fn begin_owner(
&mut self,
tier: HostTier,
plugin_id: &str,
plugin_name: &str,
authority: PluginAuthority,
authority: Option<PluginAuthority>,
content_hash: &str,
) -> OwnerRef {
) -> Result<OwnerRef, String> {
tier.check_owner_id(plugin_id)?;
match (tier, authority.is_some()) {
(HostTier::Plugin, false) => {
return Err(format!(
"plugin owner `{plugin_id}` needs its reviewed plugin authority"
));
}
(HostTier::Builtin, true) => {
return Err(format!(
"built-in module `{plugin_id}` has no plugin authority; the digest the Rust table pins is its authority"
));
}
_ => {}
}
self.revoke_owner(plugin_id);
self.next_generation += 1;
let owner = OwnerRef {
@@ -311,6 +346,7 @@ impl OwnerRegistry {
plugin_id.to_string(),
OwnerEntry {
owner: owner.clone(),
tier,
plugin_name: plugin_name.to_string(),
authority,
content_hash: content_hash.to_string(),
@@ -318,7 +354,7 @@ impl OwnerRegistry {
state: OwnerState::Activating,
},
);
owner
Ok(owner)
}
/// Record which config `owner`'s activation was given.
@@ -392,6 +428,7 @@ impl OwnerRegistry {
.ok_or_else(|| "stale or unknown owner".to_string())?;
let plugin_name = entry.plugin_name.clone();
let content_hash = entry.content_hash.clone();
let tier = entry.tier;
let spec = &params.spec;
let name = spec.name.as_str();
const COMMAND_HINT: &str = "a command name is lower case, starts with a letter, and uses only a-z, 0-9, `_` and `-` (at most 64 characters)";
@@ -476,6 +513,7 @@ impl OwnerRegistry {
CommandRegistration {
handle,
owner: params.owner.clone(),
tier,
plugin_name,
content_hash,
name: name.to_string(),
@@ -495,6 +533,7 @@ impl OwnerRegistry {
.ok_or_else(|| "stale or unknown owner".to_string())?;
let plugin_name = entry.plugin_name.clone();
let content_hash = entry.content_hash.clone();
let tier = entry.tier;
let spec = &params.spec;
let name = spec.name.as_str();
if !valid_tool_name(name) {
@@ -589,6 +628,7 @@ impl OwnerRegistry {
ToolRegistration {
handle,
owner: params.owner.clone(),
tier,
plugin_name,
content_hash,
name: name.to_string(),
@@ -690,19 +730,30 @@ impl OwnerRegistry {
.retain(|_, entry| matches!(entry.state, OwnerState::Activating | OwnerState::Active));
}
/// A crash drops live registrations, preserves failed/faulted receipts,
/// and blames the sole activating owner. Other owners are replayable only
/// after reconciliation verifies their current persisted authority again.
pub fn host_exited(&mut self, reason: &str) {
self.tools.clear();
self.by_name.clear();
self.commands.clear();
self.commands_by_name.clear();
/// Drop every registration owned by `tier`'s host: the host that held
/// them is gone, and the other tier's host is not.
fn clear_tier_registrations(&mut self, tier: HostTier) {
self.tools.retain(|_, tool| tool.tier != tier);
let tools = &self.tools;
self.by_name.retain(|_, handle| tools.contains_key(handle));
self.commands.retain(|_, command| command.tier != tier);
let commands = &self.commands;
self.commands_by_name
.retain(|_, handle| commands.contains_key(handle));
super::command::bump_epoch();
}
/// `tier`'s host exited: its crash drops its live registrations, preserves
/// its failed/faulted receipts, and blames its sole activating owner.
/// Other owners of that tier are replayable only after reconciliation
/// verifies their current persisted authority again. The other tier's
/// owners are not touched: they live in another process.
pub fn host_exited(&mut self, tier: HostTier, reason: &str) {
self.clear_tier_registrations(tier);
let activating: Vec<_> = self
.owners
.values()
.filter(|entry| entry.state == OwnerState::Activating)
.filter(|entry| entry.tier == tier && entry.state == OwnerState::Activating)
.map(|entry| entry.owner.plugin_id.clone())
.collect();
if let [plugin] = activating.as_slice() {
@@ -710,20 +761,19 @@ impl OwnerRegistry {
OwnerState::Failed(format!("host crashed during activation: {reason}"));
}
self.owners.retain(|_, entry| {
matches!(entry.state, OwnerState::Failed(_) | OwnerState::Faulted(_))
entry.tier != tier
|| matches!(entry.state, OwnerState::Failed(_) | OwnerState::Faulted(_))
});
}
/// Planned test shutdown drops all tools and fails the remaining live owners.
/// Planned test shutdown drops `tier`'s tools and fails its remaining live owners.
#[cfg(test)]
pub fn revoke_all(&mut self, reason: &str) {
self.tools.clear();
self.by_name.clear();
self.commands.clear();
self.commands_by_name.clear();
super::command::bump_epoch();
pub fn revoke_all(&mut self, tier: HostTier, reason: &str) {
self.clear_tier_registrations(tier);
for entry in self.owners.values_mut() {
if matches!(entry.state, OwnerState::Activating | OwnerState::Active) {
if entry.tier == tier
&& matches!(entry.state, OwnerState::Activating | OwnerState::Active)
{
entry.state = OwnerState::Failed(reason.to_string());
}
}
@@ -789,17 +839,32 @@ impl OwnerRegistry {
.is_some_and(|entry| entry.owner == *owner && entry.state == OwnerState::Active)
}
/// Active owners other than `plugin_id` sharing the one host process.
/// Active owners other than `plugin_id` sharing its host process (the
/// other owners of its tier).
#[must_use]
pub fn other_active_owners(&self, plugin_id: &str) -> usize {
let tier = HostTier::of_owner_id(plugin_id);
self.owners
.values()
.filter(|entry| entry.owner.plugin_id != plugin_id && entry.state == OwnerState::Active)
.filter(|entry| {
entry.tier == tier
&& entry.owner.plugin_id != plugin_id
&& entry.state == OwnerState::Active
})
.count()
}
/// The plugin authority of the exact current owner: `None` for a stale
/// owner and for a built-in module, which has none.
#[must_use]
pub fn authority_for(&self, owner: &OwnerRef) -> Option<PluginAuthority> {
self.current(owner).map(|entry| entry.authority.clone())
self.current(owner)
.and_then(|entry| entry.authority.clone())
}
/// The tier of the exact current owner.
#[must_use]
pub fn tier_of(&self, owner: &OwnerRef) -> Option<HostTier> {
self.current(owner).map(|entry| entry.tier)
}
}
+161 -22
View File
@@ -4,13 +4,20 @@
//! The existing Rust manager owns heartbeat, crash budget, generation changes,
//! and receipt-checked replay; this channel never replays a tool call.
//!
//! **Tiers** ([`HostTier`]). The host is two processes, one per trust tier,
//! started from the same bundle with `--tier=plugin|builtin` as the last
//! argument. Each has its own data directory ([`tier_data_dir`]) and its own
//! sandbox plan; the plugin tier's also denies reads of the builtin tier's
//! data directory ([`host_denied_read_paths`]). Only the plugin tier starts
//! in production today.
//!
//! **OS sandbox** ([`HostSandbox`]: the one value `/plugin`, doctor and the
//! start diagnostic report). The host runs under Codewhale's command sandbox
//! with a workspace-write policy rooted at
//! `$CODEWHALE_HOME/extension-host/data`: no direct network, writes only
//! there and in the temp dirs, and **no reads** of the Codewhale homes
//! (everything but the bundle, its data dir and plugin code), the Codex and
//! DSH credential homes, and the credential-store default deny-list
//! with a workspace-write policy rooted at its tier's data directory (the
//! plugin tier's is `$CODEWHALE_HOME/extension-host/data`): no direct network,
//! writes only there and in the temp dirs, and **no reads** of the Codewhale
//! homes (everything but the bundle, its data dir and plugin code), the Codex
//! and DSH credential homes, and the credential-store default deny-list
//! (`sandbox::read_guard`). Other user-readable files stay readable —
//! including `.env` files, whose filename rule has no Seatbelt subpath or
//! bubblewrap mount form — so this is defense-in-depth, not a containment
@@ -93,6 +100,7 @@ use super::protocol::{
self, CoreRequest, HostLimits, HostMessage, HostNotification, HostRequest, InitializeParams,
RegisterResult, error_code,
};
use super::tier::HostTier;
/// Budget for spawn → `host/hello` → `host/initialize` → `host/ready`.
///
@@ -138,6 +146,8 @@ pub enum HostCallError {
/// available), its working directory, and which sandbox applies.
#[derive(Debug, Clone)]
pub(crate) struct HostLaunch {
/// The trust tier this host process serves (`--tier=` in its argv).
pub tier: HostTier,
pub program: PathBuf,
pub args: Vec<String>,
pub cwd: PathBuf,
@@ -371,7 +381,14 @@ const HOST_DENIED_HOME_ENTRIES: &[&str] = &[
/// is denied whole and its readable entries come back as exceptions to bind
/// again. Without it (Seatbelt, which matches paths that do not exist yet)
/// every other entry is denied by name and there are no exceptions.
///
/// The plugin tier also denies the builtin tier's data directory, which lies
/// inside the readable `extension-host/` entry: plugin code never reads
/// tier-0 state. (Under bubblewrap a directory that does not exist when the
/// plugin host starts cannot be masked, so tier-0 state created later is
/// visible to a plugin host that was already running.)
pub(crate) fn host_denied_read_paths(
tier: HostTier,
home: &Path,
whole_homes: bool,
) -> (Vec<PathBuf>, Vec<PathBuf>) {
@@ -390,6 +407,10 @@ pub(crate) fn host_denied_read_paths(
roots.push(user.join(".deepseek"));
}
for root in roots {
// The builtin tier's data directory, inside the readable
// `extension-host/` entry of every Codewhale home: not for plugin code.
let tier_zero_data =
(tier == HostTier::Plugin).then(|| tier_data_dir(&root, HostTier::Builtin));
if whole_homes {
for entry in HOST_READABLE_HOME_ENTRIES {
let readable = root.join(entry);
@@ -397,6 +418,9 @@ pub(crate) fn host_denied_read_paths(
exceptions.push(readable);
}
}
if let Some(dir) = tier_zero_data {
push(dir);
}
push(root);
continue;
}
@@ -416,6 +440,12 @@ pub(crate) fn host_denied_read_paths(
// exist yet cannot be canonicalized later, so deny it under both the
// given and the resolved spelling of its (existing) root.
let resolved = std::fs::canonicalize(&root).ok();
if let Some(dir) = tier_zero_data {
if let Some(resolved) = &resolved {
push(tier_data_dir(resolved, HostTier::Builtin));
}
push(dir);
}
for name in names {
let readable = name
.to_str()
@@ -443,34 +473,77 @@ pub(crate) fn host_denied_read_paths(
(paths, exceptions)
}
/// Plan the host launch. Blocking (creates the data dir, canonicalizes the
/// deny-list, and on Linux runs the bwrap probe); call from `spawn_blocking`.
/// Plan the host launch for `tier`: the runtime's flags, the bundle, then the
/// tier (`--tier=plugin|builtin`, which the host reads from its own argv).
/// Blocking (creates the tier's data dir, canonicalizes the deny-list, and on
/// Linux runs the bwrap probe); call from `spawn_blocking`.
pub(crate) fn plan_launch(
tier: HostTier,
runtime: &HostRuntime,
bundle: &Path,
home: &Path,
memory_cap: u64,
) -> Result<HostLaunch, String> {
let data = host_data_dir(home)?;
let data = host_data_dir(home, tier)?;
let mut args = runtime_args(runtime);
args.push(bundle.to_string_lossy().into_owned());
let wrapped = wrap_host(&runtime.path, &args, &data, home);
host_launch(runtime, args, data, memory_cap, wrapped)
args.push(tier.argv_flag());
let wrapped = wrap_host(tier, &runtime.path, &args, &data, home);
host_launch(tier, runtime, args, data, memory_cap, wrapped)
}
/// The sandbox a host started now would get, planned (and on Linux probed)
/// exactly as [`plan_launch`] does, for doctor. Blocking; creates the data
/// dir, as a launch would.
pub(crate) fn planned_sandbox(runtime: &HostRuntime, home: &Path) -> Result<HostSandbox, String> {
let data = host_data_dir(home)?;
/// The sandbox a `tier` host started now would get, planned (and on Linux
/// probed) exactly as [`plan_launch`] does, for doctor. Blocking; creates the
/// data dir, as a launch would.
pub(crate) fn planned_sandbox(
tier: HostTier,
runtime: &HostRuntime,
home: &Path,
) -> Result<HostSandbox, String> {
let data = host_data_dir(home, tier)?;
Ok(
match wrap_host(&runtime.path, &runtime_args(runtime), &data, home) {
match wrap_host(tier, &runtime.path, &runtime_args(runtime), &data, home) {
Ok(wrapped) => HostSandbox::Wrapped(wrapped.name),
Err(reason) => HostSandbox::Unsandboxed(reason),
},
)
}
/// A tier's data directory: its host's working directory and, under the OS
/// sandbox, its only writable root. Pure.
///
/// The plugin tier keeps the directory it has always had,
/// `extension-host/data`, because the per-plugin directories under it
/// ([`plugin_data_dir`]) hold installed plugins' data and moving it would lose
/// that. The builtin tier's is a sibling, `extension-host/data-builtin`, not a
/// child: a child would lie inside the plugin tier's writable root, and the
/// host sandbox has no per-subpath write deny, so plugin code could then write
/// tier-0 state.
pub(crate) fn tier_data_dir(home: &Path, tier: HostTier) -> PathBuf {
let base = home.join("extension-host");
match tier {
HostTier::Plugin => base.join("data"),
HostTier::Builtin => base.join("data-builtin"),
}
}
/// The directory an owner's code is given as its own: a plugin's
/// ([`plugin_data_dir`], unchanged), or `modules/<module>` under the builtin
/// tier's data directory for a built-in module (`plugin_name` is the module
/// name, which [`HostTier::check_owner_id`] has restricted to a plain name).
/// Pure.
pub(crate) fn owner_data_dir(
home: &Path,
tier: HostTier,
plugin_id: &str,
plugin_name: &str,
) -> PathBuf {
match tier {
HostTier::Plugin => plugin_data_dir(home, plugin_id, plugin_name),
HostTier::Builtin => tier_data_dir(home, tier).join("modules").join(plugin_name),
}
}
/// One plugin's own directory inside the host's data dir, which is the host
/// sandbox's writable root: stable for one plugin id (so it survives updates
/// and restarts), distinct per plugin, and a single path component under
@@ -501,8 +574,8 @@ pub(crate) fn plugin_data_dir(home: &Path, plugin_id: &str, plugin_name: &str) -
.join(format!("{name}-{short}"))
}
fn host_data_dir(home: &Path) -> Result<PathBuf, String> {
let data = home.join("extension-host").join("data");
fn host_data_dir(home: &Path, tier: HostTier) -> Result<PathBuf, String> {
let data = tier_data_dir(home, tier);
std::fs::create_dir_all(&data)
.map_err(|error| format!("cannot create {}: {error}", data.display()))?;
Ok(data)
@@ -524,7 +597,13 @@ const WINDOWS_UNSANDBOXED: &str = "Windows has no host sandbox yet; its Job Obje
/// Wrap `program args` in the host's OS sandbox (module docs), or say why it
/// has none here. Blocking.
fn wrap_host(program: &Path, args: &[String], data: &Path, home: &Path) -> Result<Wrapped, String> {
fn wrap_host(
tier: HostTier,
program: &Path,
args: &[String],
data: &Path,
home: &Path,
) -> Result<Wrapped, String> {
use crate::sandbox::{CommandSpec, SandboxManager, SandboxPolicy, SandboxType};
if cfg!(windows) {
return Err(WINDOWS_UNSANDBOXED.to_string());
@@ -545,7 +624,7 @@ fn wrap_host(program: &Path, args: &[String], data: &Path, home: &Path) -> Resul
};
let bwrap = cfg!(all(target_os = "linux", not(target_env = "ohos")));
let mut manager = SandboxManager::with_bwrap_preference(bwrap);
let (denied, exceptions) = host_denied_read_paths(home, bwrap);
let (denied, exceptions) = host_denied_read_paths(tier, home, bwrap);
manager.set_denied_read_subpaths(denied);
manager.set_denied_read_exceptions(exceptions);
let env = manager.prepare(&spec(args.to_vec()));
@@ -571,6 +650,7 @@ fn wrap_host(program: &Path, args: &[String], data: &Path, home: &Path) -> Resul
/// The launch for a [`wrap_host`] outcome: the wrapper's argv, or the
/// runtime itself, unsandboxed, carrying the reason `/plugin` shows. Pure.
fn host_launch(
tier: HostTier,
runtime: &HostRuntime,
args: Vec<String>,
data: PathBuf,
@@ -598,6 +678,7 @@ fn host_launch(
),
};
Ok(HostLaunch {
tier,
program,
args,
cwd: data,
@@ -813,6 +894,8 @@ struct Handshake {
}
pub(crate) struct HostProcess {
/// The tier this process was launched for.
pub tier: HostTier,
pub pid: Option<u32>,
/// The runtime the Rust side launched; `host/hello` must agree.
pub runtime: HostRuntime,
@@ -1055,6 +1138,7 @@ impl HostProcess {
}
let host = Arc::new(Self {
tier: launch.tier,
pid,
runtime: launch.runtime.clone(),
runtime_version: std::sync::OnceLock::new(),
@@ -1534,6 +1618,7 @@ mod tests {
"{refused}"
);
let launch = host_launch(
HostTier::Plugin,
&runtime,
args.clone(),
data.clone(),
@@ -1570,6 +1655,7 @@ mod tests {
.chain(args.iter().cloned())
.collect();
let launch = host_launch(
HostTier::Plugin,
&runtime,
args.clone(),
data.clone(),
@@ -1598,6 +1684,59 @@ mod tests {
);
}
/// Each tier has its own data directory, and neither is inside the other:
/// the data directory is the host's only writable root, so a nested
/// builtin directory would be writable by plugin code. The plugin tier
/// keeps the path it has always had, and the plugin tier's sandbox denies
/// reads of the builtin tier's directory.
#[test]
fn each_tier_has_its_own_data_directory_and_the_plugin_tier_cannot_read_the_builtin_one() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
std::fs::create_dir_all(tier_data_dir(&home, HostTier::Builtin)).unwrap();
let plugin = tier_data_dir(&home, HostTier::Plugin);
let builtin = tier_data_dir(&home, HostTier::Builtin);
assert_ne!(plugin, builtin);
assert!(!builtin.starts_with(&plugin) && !plugin.starts_with(&builtin));
assert_eq!(plugin, home.join("extension-host").join("data"));
for whole_homes in [false, true] {
let (denied, _) = host_denied_read_paths(HostTier::Plugin, &home, whole_homes);
assert!(
denied.contains(&builtin),
"plugin tier (whole_homes {whole_homes}) must deny {}",
builtin.display()
);
assert!(!denied.contains(&plugin));
let (denied, _) = host_denied_read_paths(HostTier::Builtin, &home, whole_homes);
assert!(
!denied.contains(&builtin),
"the builtin tier reads its own directory"
);
}
}
/// The per-plugin directory plugin config and context introduced keeps its
/// path, so an installed plugin's data survives the tier split; a built-in
/// module's is under the builtin tier's directory.
#[test]
fn the_per_plugin_data_directory_keeps_its_path() {
let home = PathBuf::from("/home/u/.codewhale");
let id = "user/0123456789ab/demo";
assert_eq!(
plugin_data_dir(&home, id, "demo"),
home.join("extension-host/data/plugins/demo-e9f63c6f1a7f")
);
assert_eq!(
owner_data_dir(&home, HostTier::Plugin, id, "demo"),
plugin_data_dir(&home, id, "demo")
);
assert_eq!(
owner_data_dir(&home, HostTier::Builtin, "host:mcp", "mcp"),
home.join("extension-host/data-builtin/modules/mcp")
);
}
/// bubblewrap can mask only what exists, so under it each Codewhale home
/// is denied whole (an entry created later is then denied too) and its
/// readable entries come back as exceptions; Seatbelt's form is unchanged.
@@ -1608,7 +1747,7 @@ mod tests {
std::fs::create_dir_all(home.join("extension-host")).unwrap();
std::fs::write(home.join("config.toml.bak-1"), "").unwrap();
let (seatbelt, none) = host_denied_read_paths(&home, false);
let (seatbelt, none) = host_denied_read_paths(HostTier::Plugin, &home, false);
assert!(none.is_empty());
assert!(seatbelt.contains(&home.join("config.toml.bak-1")));
assert!(
@@ -1618,7 +1757,7 @@ mod tests {
assert!(!seatbelt.contains(&home));
assert!(!seatbelt.contains(&home.join("extension-host")));
let (bwrap, exceptions) = host_denied_read_paths(&home, true);
let (bwrap, exceptions) = host_denied_read_paths(HostTier::Plugin, &home, true);
assert!(bwrap.contains(&home));
assert!(!bwrap.contains(&home.join("config.toml.bak-1")));
for entry in HOST_READABLE_HOME_ENTRIES {
File diff suppressed because it is too large. Load diff
+327
View File
@@ -0,0 +1,327 @@
//! Trust tiers of the extension host.
//!
//! One process is one trust domain (design §1.5), so the host runs as two
//! processes that never share code, state or a data directory:
//!
//! * **Plugin** (tier 1): reviewed third-party plugins. Owner ids are the
//! plugin ids discovery builds (`<scope>/<12 hex>/<name>`). This is the only
//! tier that spawns in production today, and everything about it is as it
//! was before the split.
//! * **Builtin** (tier 0): Codewhale's own host code, so that it never shares
//! a process with third-party code. Owner ids are `host:<module>`. A module
//! is admitted by a row of [`BUILTIN_MODULES`], which pins the SHA-256 of its
//! source and names each of its tools with the approval the core gives it
//! (`Auto` or `Required`). The table is Rust data: nothing a module says
//! about itself can lower its approval, and a tool the table does not list is
//! `Required`.
//!
//! The two id spaces cannot meet. A plugin id starts with a scope name and a
//! manifest name cannot hold `:`, so discovery never builds a `host:` id;
//! [`HostTier::check_owner_id`] is the one place that says so, and the owner
//! registry refuses a `host:` id on the plugin tier and any other id on the
//! builtin tier (`OwnerRegistry::begin_owner`).
//!
//! Known limitations (this is the structural split, no more):
//! * [`BUILTIN_MODULES`] is empty, so nothing asks for the builtin tier and it
//! never spawns. "Needed" means "has a row" until the first consumer brings
//! a reason to defer the spawn.
//! * No protocol change: `host/hello` does not report a tier, and no method
//! has a tier allow-list, so the core cannot tell a host which tier it runs
//! beyond its `--tier=` argument, and the host's own refusal of an owner of
//! the other tier is the only check on its side.
//! * No capability tickets: a tier-0 module can ask for nothing the protocol
//! did not already let a plugin ask for. They land with their first redeemer.
//! * The pinned digest is drift protection (a changed module no longer
//! matches its row), not a defence against a process already running as the
//! user, which can replace the file and the table's readers alike.
//! * The source of a module is expected at
//! `<bundle dir>/builtin/<module>.mjs`; nothing embeds or materializes one
//! there yet, because there is no module.
use crate::tools::spec::ApprovalRequirement;
/// Every tier-0 owner id starts with this: `host:<module>`.
pub(crate) const HOST_OWNER_PREFIX: &str = "host:";
/// Which of the two host processes something belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum HostTier {
/// Codewhale's own host code (tier 0).
Builtin,
/// Reviewed third-party plugins (tier 1).
Plugin,
}
impl HostTier {
/// Plugin first: it is the tier that exists in production.
pub(crate) const ALL: [Self; 2] = [Self::Plugin, Self::Builtin];
/// The value of `--tier=` and of the data directory's name.
#[must_use]
pub(crate) fn name(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::Plugin => "plugin",
}
}
/// The argument the core launches this tier's host with.
#[must_use]
pub(crate) fn argv_flag(self) -> String {
format!("--tier={}", self.name())
}
/// How diagnostics and errors name this tier's host process. The plugin
/// tier keeps the name it has always had.
#[must_use]
pub(crate) fn host_label(self) -> &'static str {
match self {
Self::Builtin => "built-in extension host",
Self::Plugin => "extension host",
}
}
/// The tier an owner id belongs to. Total: the id decides.
#[must_use]
pub(crate) fn of_owner_id(owner_id: &str) -> Self {
if owner_id.starts_with(HOST_OWNER_PREFIX) {
Self::Builtin
} else {
Self::Plugin
}
}
/// Whether `owner_id` may be an owner on this tier, or why not.
pub(crate) fn check_owner_id(self, owner_id: &str) -> Result<(), String> {
match self {
Self::Plugin if owner_id.starts_with(HOST_OWNER_PREFIX) => Err(format!(
"`{owner_id}` is in the built-in host namespace (`{HOST_OWNER_PREFIX}<module>`); a plugin id can never use it"
)),
Self::Plugin => Ok(()),
Self::Builtin => match owner_id.strip_prefix(HOST_OWNER_PREFIX) {
Some(module) if valid_module_id(module) => Ok(()),
_ => Err(format!(
"`{owner_id}` is not a built-in host module id: the built-in tier takes only `{HOST_OWNER_PREFIX}<module>` (a lower-case module name of letters, digits and `-`)"
)),
},
}
}
}
/// `^[a-z][a-z0-9-]{0,63}$`: a module name is also a file name and a
/// directory name, so it is as plain as one.
fn valid_module_id(module: &str) -> bool {
let mut chars = module.chars();
matches!(chars.next(), Some(first) if first.is_ascii_lowercase())
&& module.len() <= 64
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// One tool of a built-in module and the approval the core gives it: `Auto` or
/// `Required` (the core's own [`ApprovalRequirement`]; [`tool_approval`] reads
/// anything but `Auto` as `Required`). Only this table can say `Auto`.
#[derive(Debug)]
pub(crate) struct Tier0Tool {
pub name: &'static str,
pub approval: ApprovalRequirement,
}
/// One built-in host module: Codewhale's own code, pinned.
#[derive(Debug)]
pub(crate) struct BuiltinModule {
/// The module name; its owner id is `host:<id>`.
pub id: &'static str,
/// SHA-256 (lower-case hex) of the module's source file, as the host
/// build records it in `dist/builtin-modules.json`. The core refuses to
/// activate a file with any other digest.
pub source_sha256: &'static str,
pub tools: &'static [Tier0Tool],
}
impl BuiltinModule {
/// The owner id this module activates under.
#[must_use]
pub(crate) fn owner_id(&self) -> String {
format!("{HOST_OWNER_PREFIX}{}", self.id)
}
}
/// The built-in modules the core knows. Empty: no Codewhale feature runs on
/// the host yet, so the builtin tier never starts. A row is added together
/// with the module, its digest in `dist/builtin-modules.json` (the host build
/// writes it; `table_matches_the_host_build` fails until they agree), and the
/// feature that needs it.
pub(crate) const BUILTIN_MODULES: &[BuiltinModule] = &[];
/// The approval for `tool` of the module that owns `owner_id`, from `modules`
/// and nowhere else: `Auto` only where a row says so, `Required` for a module
/// or tool the table does not list and for any row that says anything else.
#[must_use]
pub(crate) fn tool_approval(
modules: &[BuiltinModule],
owner_id: &str,
tool: &str,
) -> ApprovalRequirement {
let listed = owner_id
.strip_prefix(HOST_OWNER_PREFIX)
.and_then(|module_id| modules.iter().find(|module| module.id == module_id))
.and_then(|module| module.tools.iter().find(|listed| listed.name == tool));
match listed {
Some(Tier0Tool {
approval: ApprovalRequirement::Auto,
..
}) => ApprovalRequirement::Auto,
_ => ApprovalRequirement::Required,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
/// The host build (`extension-host/build.mjs`) writes the digest of every
/// built-in module's source to `dist/builtin-modules.json`; the Rust table
/// must say exactly the same, both ways: a module with no row, a row with
/// no module and a changed source all fail here. Same pattern as the
/// generated-protocol drift test in `protocol/tests.rs`, except the build
/// is the source of truth, so the fix is to update the table.
#[test]
fn table_matches_the_host_build() {
let built: serde_json::Value = serde_json::from_str(include_str!(
"../../extension-host/dist/builtin-modules.json"
))
.expect("dist/builtin-modules.json is JSON");
let built: BTreeMap<String, String> = built["modules"]
.as_object()
.expect("`modules` is an object")
.iter()
.map(|(id, digest)| (id.clone(), digest.as_str().expect("a digest").to_string()))
.collect();
let table: BTreeMap<String, String> = BUILTIN_MODULES
.iter()
.map(|module| (module.id.to_string(), module.source_sha256.to_string()))
.collect();
assert_eq!(
table.len(),
BUILTIN_MODULES.len(),
"BUILTIN_MODULES lists a module twice"
);
assert_eq!(
table, built,
"BUILTIN_MODULES and crates/tui/extension-host/dist/builtin-modules.json disagree. \
Rebuild the host (`npm run build` in crates/tui/extension-host) and make the table \
list exactly the modules and digests the build recorded."
);
}
#[test]
fn every_row_is_well_formed() {
for module in BUILTIN_MODULES {
assert!(valid_module_id(module.id), "{}", module.id);
assert!(
module.source_sha256.len() == 64
&& module
.source_sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"{}: not a lower-case SHA-256",
module.id
);
assert_eq!(HostTier::of_owner_id(&module.owner_id()), HostTier::Builtin);
}
}
#[test]
fn the_two_id_spaces_cannot_meet() {
assert!(
HostTier::Plugin
.check_owner_id("user/0123456789ab/demo")
.is_ok()
);
assert!(HostTier::Plugin.check_owner_id("a").is_ok());
assert!(HostTier::Builtin.check_owner_id("host:mcp").is_ok());
for host_id in ["host:mcp", "host:", "host:evil name"] {
let refused = HostTier::Plugin.check_owner_id(host_id).unwrap_err();
assert!(
refused.contains("a plugin id can never use it"),
"{refused}"
);
}
for id in [
"user/0123456789ab/demo",
"mcp",
"host",
"host:",
"host:UPPER",
"host:../x",
"host:a/b",
"HOST:mcp",
] {
let refused = HostTier::Builtin.check_owner_id(id).unwrap_err();
assert!(
refused.contains("not a built-in host module id"),
"{id}: {refused}"
);
}
assert_eq!(HostTier::of_owner_id("host:mcp"), HostTier::Builtin);
assert_eq!(
HostTier::of_owner_id("user/0123456789ab/demo"),
HostTier::Plugin
);
}
#[test]
fn a_tier_zero_tool_is_required_unless_the_table_says_otherwise() {
const TOOLS: &[Tier0Tool] = &[
Tier0Tool {
name: "open",
approval: ApprovalRequirement::Auto,
},
Tier0Tool {
name: "write",
approval: ApprovalRequirement::Required,
},
Tier0Tool {
name: "suggest",
approval: ApprovalRequirement::Suggest,
},
];
let table = [BuiltinModule {
id: "demo",
source_sha256: "0000000000000000000000000000000000000000000000000000000000000000",
tools: TOOLS,
}];
assert_eq!(
tool_approval(&table, "host:demo", "open"),
ApprovalRequirement::Auto
);
assert_eq!(
tool_approval(&table, "host:demo", "write"),
ApprovalRequirement::Required
);
// Only `Auto` lowers anything: any other row reads as `Required`.
assert_eq!(
tool_approval(&table, "host:demo", "suggest"),
ApprovalRequirement::Required
);
// Unlisted tool, unlisted module, and an id that is not tier 0 at all.
assert_eq!(
tool_approval(&table, "host:demo", "other"),
ApprovalRequirement::Required
);
assert_eq!(
tool_approval(&table, "host:other", "open"),
ApprovalRequirement::Required
);
assert_eq!(
tool_approval(&table, "demo", "open"),
ApprovalRequirement::Required
);
assert_eq!(
tool_approval(&[], "host:demo", "open"),
ApprovalRequirement::Required
);
}
}
+45 -13
View File
@@ -5,10 +5,14 @@
//! mode (which suspends gated calls for approval and refuses ungated calls
//! before any host call). Four rules are specific to extension tools:
//!
//! * **Always `ApprovalRequirement::Required`.** A plugin's own read-only
//! hint (`presentCall` `kind: 'read'`, MCP-style annotations) is display
//! data at most. Honouring it would let a plugin switch approval off for a
//! tool whose body runs arbitrary Node — self-approval.
//! * **A plugin's tool is always `ApprovalRequirement::Required`.** A plugin's
//! own read-only hint (`presentCall` `kind: 'read'`, MCP-style annotations)
//! is display data at most. Honouring it would let a plugin switch approval
//! off for a tool whose body runs arbitrary Node — self-approval. A tool of
//! a built-in module (tier 0, owner `host:<module>`) gets the approval the
//! Rust table [`super::tier::BUILTIN_MODULES`] lists for it and nothing the
//! module says; unlisted is `Required`. It is still never read-only for
//! plan mode.
//! * **Approval grants are receipt-bound.** Keys are
//! `ext:<plugin_id>@<content_hash>:<name>:<hash(input)>` for both the exact
//! and the session-grant key ([`ToolSpec::approval_scope`]), so an updated
@@ -32,6 +36,7 @@ use super::ManagerShared;
use super::protocol::{ContentBlockWire, CoreRequest, ToolCallParams, ToolResultWire};
use super::registry::ToolRegistration;
use super::supervisor::HostCallError;
use super::tier::{self, HostTier};
use crate::tools::spec::{
ApprovalRequirement, PreparedToolCall, ToolCapability, ToolContext, ToolError, ToolResult,
ToolSpec,
@@ -54,10 +59,28 @@ impl HostToolSpec {
}
}
/// `extension:<plugin>`: the origin shown in approval cards and diagnostics.
/// `extension:<plugin>` (or the module's owner id, `host:<module>`): the
/// origin shown in approval cards and diagnostics.
#[must_use]
pub fn origin(&self) -> String {
format!("extension:{}", self.registration.plugin_name)
match self.registration.tier {
HostTier::Plugin => format!("extension:{}", self.registration.plugin_name),
HostTier::Builtin => self.registration.owner.plugin_id.clone(),
}
}
/// The approval this tool needs: always `Required` for a plugin's tool;
/// for a built-in module's, what the Rust table says and `Required` when
/// it says nothing.
fn approval(&self) -> ApprovalRequirement {
match self.registration.tier {
HostTier::Plugin => ApprovalRequirement::Required,
HostTier::Builtin => tier::tool_approval(
self.manager.builtin_modules,
&self.registration.owner.plugin_id,
&self.registration.name,
),
}
}
/// Check `input` against the schema the plugin registered the tool with.
@@ -80,6 +103,14 @@ impl HostToolSpec {
/// so the card says when this one is not alone (design §4.4, threat 3).
#[must_use]
pub fn approval_text(&self) -> String {
if self.registration.tier == HostTier::Builtin {
return format!(
"Tool `{}` of built-in host module `{}` ({}) runs on Codewhale's built-in extension host",
self.registration.name,
self.registration.plugin_name,
self.origin()
);
}
let others = self
.manager
.registry
@@ -174,18 +205,19 @@ impl ToolSpec for HostToolSpec {
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![
ToolCapability::ExecutesCode,
ToolCapability::RequiresApproval,
]
let mut capabilities = vec![ToolCapability::ExecutesCode];
if self.approval() != ApprovalRequirement::Auto {
capabilities.push(ToolCapability::RequiresApproval);
}
capabilities
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
self.approval()
}
fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
ApprovalRequirement::Required
self.approval()
}
fn is_read_only(&self) -> bool {
@@ -219,7 +251,7 @@ impl ToolSpec for HostToolSpec {
read_only: false,
supports_parallel: false,
starts_detached: false,
approval: ApprovalRequirement::Required,
approval: self.approval(),
resources: vec![crate::tools::spec::ResourceClaim::GlobalExclusive],
input,
})
@@ -0,0 +1,21 @@
// A built-in (tier 0) host module for the trust-tier tests. It is not a
// plugin: no manifest, no review. The test substitutes a builtin-module table
// pinning this file's SHA-256 and listing `zz_tier0_listed` as Auto; the other
// tool is not listed, so the core must treat it as Required.
export const name = 'tier0-module'
export const inject = ['tools']
export function apply(ctx) {
ctx.tools.register({
name: 'zz_tier0_listed',
description: 'Listed in the test table with Auto approval.',
parameters: { type: 'object', properties: {} },
execute: () => ({ tier: 'zero', listed: true }),
})
ctx.tools.register({
name: 'zz_tier0_unlisted',
description: 'Not listed in the test table: Required.',
parameters: { type: 'object', properties: {} },
execute: () => ({ tier: 'zero', listed: false }),
})
}
+94
View File
@@ -8,6 +8,100 @@
> from the text that follows. Where they disagree, the newest "As built"
> section and the code are current; the rest is the plan for later phases.
## As built: trust tiers (2026-10-02)
Slice A1 of the tier plan (CURRENT_DECISIONS §26 D3, §1.5 above): the host is
now two processes' worth of machinery, one per trust tier, and nothing else
about it changed. No wire change, no new behaviour for a plugin author. What
exists:
- **Two tiers** (`extension_host/tier.rs`, `HostTier { Plugin, Builtin }`).
*Plugin* hosts reviewed third-party plugins; its owner ids are the plugin ids
discovery builds (`<scope>/<12 hex>/<name>`). *Builtin* (tier 0) is for
Codewhale's own host code and its owner ids are `host:<module>`. The id
spaces cannot meet: `OwnerRegistry::begin_owner` takes the tier and refuses a
`host:` id on the plugin tier and any other id on the builtin tier, and an
authority that does not fit (a plugin owner needs its reviewed plugin
authority, a module has none); every owner entry, tool and command
registration records its tier. Discovery cannot produce a `host:` id (a
manifest name cannot hold `:`, and an id starts with a scope name), a test
shows such a plugin failing validation, and `desired_owners` checks the id
once more before it reaches the host.
- **An empty production table.** `BUILTIN_MODULES` (`BuiltinModule { id,
source_sha256, tools: &[Tier0Tool { name, approval }] }`) is empty, so
nothing asks for the builtin tier and **it never spawns**; plugins behave
exactly as before. "Needed" currently means "has a row". A tier-0 tool's
approval comes only from this Rust table (`Auto` or `Required`); an unlisted
tool or module is `Required`, and the module's own say changes nothing. It is
still never read-only for plan mode. The manager holds the table
(`ManagerShared::builtin_modules`, the production const unless a test builds
the manager with `with_builtin_modules`), so a test exercises tier 0 without
a production row. A module's source is
expected at `<bundle dir>/builtin/<module>.mjs` and is activated only if its
SHA-256 is the pinned one (otherwise a failed owner with the reason, no host
started); nothing materializes such a file yet because there is no module.
- **Per-tier supervision.** `ManagerShared` holds two `TierRuntime`s (host
slot, generation, spawn count, crash and restart state), lazily spawned. The
owner registry, the runtime pin, the attachments and plugin settings stay
shared, so both hosts run on the same pinned runtime and one registry says
which tier an owner is in. A host's exit, restart, heartbeat and dirty
teardown touch only its own tier (`OwnerRegistry::host_exited(tier, ..)`).
A host answers `registry/*`, `log` and the like only for owners of its own
tier. Lock order, restated at `ManagerShared` and kept: `sync_lock`, then one
tier's host slot, then that tier's supervision state, then the registry; no
path holds both tiers' slots, and the other locks are leaves.
- **Supervisor.** `plan_launch` and the sandbox plan take a tier. The argv ends
`<bundle> --tier=plugin|builtin`. The plugin tier's data directory, working
directory and writable root are what they always were,
`extension-host/data`, so the per-plugin directories under it
(`data/plugins/<name>-<hex>`, unchanged and pinned by a test) keep their
data. The builtin tier's is **`extension-host/data-builtin`**, a sibling and
not `data/builtin`: a child would lie inside the plugin tier's writable root,
and the host sandbox has no per-subpath write deny, so plugin code could
then write tier-0 state. The plugin tier's read deny list names the builtin
directory explicitly (unit test on the path function). A built-in module's
own directory is `data-builtin/modules/<module>`. Under bubblewrap a builtin
directory that did not exist when the plugin host started cannot be masked;
Seatbelt denies it by name before it exists.
- **Host.** `--tier=` is parsed before anything else; an unknown value, a bare
`--tier` or a tier named twice exits with 64 before `host/hello` (no
`--tier` means `plugin`, the least-privileged). `ext/activate` for an owner
of the other tier (decided by the `host:` prefix) is refused with
`InvalidParams`, before anything is read or loaded. `dist/` was rebuilt.
- **Drift check.** `build.mjs` builds each `src/builtin/<id>.ts` to
`dist/builtin/<id>.mjs` and writes the SHA-256 of each to
`dist/builtin-modules.json` (`{"modules": {}}` today); a Rust test
(`tier::tests::table_matches_the_host_build`) fails when `BUILTIN_MODULES` and
that file disagree in either direction. A module that would bundle a
`node_modules` package fails the build until its licence notice is handled.
- **Activation policy** is untouched: v4 and its pinned digest test pass
without change, because tier-0 modules are not plugins and no installed
plugin is re-reviewed.
- **Tests.** Rust: owner/tier refusals and per-tier crash isolation in the
registry, a host answering only for its own tier, a `host:`-named manifest
failing validation, launch plans (argv, data directories, deny list, the
unchanged per-plugin path), the drift test, the table's approval lookup, a
tampered or missing module refused with no host started, production never
spawning the builtin tier, and (test table) a tier-0 host spawning apart from
the plugin host, activating `host:tier0-module`, its tools' approval
following the table, a call through it, and a plugin-host crash leaving it
alone. JS: refusal of bad `--tier` values, each tier refusing the other's
owners, `host:` activation on the builtin tier, and the dist digest file
matching `dist/builtin/`.
Not done, and not claimed: any real tier-0 module (the MCP move, §5, is the
first consumer); `host/hello` reporting a tier or module digests and a
per-method tier allow-list (slice A2: until then the core cannot tell what
tier a host believes it serves beyond its argv, and a tier-1 host is not
refused `proc/*`, `net/*` or `mcp/*` because none of those exist); capability
tickets (they land with their first redeemer, `core/call`); embedding or
materializing a module's source; a bundled tier-0 executable (D1); a demand
predicate that defers the builtin spawn until something needs it; the builtin
tier's sandbox denying the plugin tier's data directory (it can read it today);
`/plugin` listing the builtin host's modules and tools beyond one status line;
tier-0 commands or tools being offered to the model (an engine installs plugin
owners' tools only).
## As built: plugin context, several entries, input validation, notices (2026-10-01)
A review of the commands slice found four defects and one gap in what an author