mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
Merge remote-tracking branch 'origin/wave/0.10.1-next' into land-security-1002
This commit is contained in:
commit
7f3fcb285c
17 files changed
+2471
-334
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"modules": {}
|
||||
}
|
||||
+40
-2
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ¶ms.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 = ¶ms.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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user