From f9c9be829bae23b132e2f5d08aa0d116a51c18b7 Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 8 Aug 2026 18:14:27 +0800 Subject: [PATCH] feat(protocol): add runtime/capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialize() already reports capabilities, but every flag in it is a protocol feature — threadResume, workspaceDiff, reviewActions. No client can ask the question that actually matters before trusting a runtime: where will this write, and what will it stop to ask me about? Adds `runtime/capabilities`, kept separate from initialize on purpose. That one answers "which methods work"; this one answers "what authority does this runtime hold over my machine". The split is also the rule for where the next field goes. Both the CLI and the app-server build the payload through one function in core, and a test asserts they agree field-for-field on the same settings — sandbox posture, write scope, contract status, rule counts, ledger path. The alignment plan's P0 is that permissions and tool execution are not a unified runtime capability; this makes that claim executable instead of aspirational. VS Code and the LSP are thin protocol clients, so they get the server's answer verbatim. Two deliberate choices in the payload: - writeScope reports [""] under danger-full-access, not []. An empty array reads as "writes nowhere", the exact opposite of the truth and the worst thing this could get wrong. - Permission rules are reported as counts, not contents. The rules can hold user paths; a count answers "is anything configured" without handing them out. `deepcode doctor` now prints the same declaration, so it cannot describe a posture the runtime does not have. Adding the capability flag broke three client fixtures at compile time, which is the signal working as intended. Co-Authored-By: Claude Opus 5 --- apps/cli/src/cli.ts | 28 +++- apps/desktop/src/lib/protocol-agent.test.ts | 1 + apps/lsp/src/handler.test.ts | 1 + apps/server/src/capabilities.test.ts | 125 ++++++++++++++++++ apps/server/src/capabilities.ts | 44 ++++++ apps/server/src/index.ts | 1 + apps/server/src/run.ts | 2 + apps/server/src/server.ts | 8 ++ apps/vscode/src/protocol-runtime.test.ts | 1 + docs/design/app-server-v1.md | 78 ++++++++--- packages/core/src/index.ts | 8 ++ .../core/src/runtime/capabilities.test.ts | 94 +++++++++++++ packages/core/src/runtime/capabilities.ts | 121 +++++++++++++++++ packages/core/src/runtime/host.ts | 33 ++++- packages/core/src/runtime/index.ts | 6 + packages/protocol/src/runtime.test.ts | 1 + packages/protocol/src/runtime.ts | 2 + packages/protocol/src/types.ts | 28 ++++ 18 files changed, 564 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/capabilities.test.ts create mode 100644 apps/server/src/capabilities.ts create mode 100644 packages/core/src/runtime/capabilities.test.ts create mode 100644 packages/core/src/runtime/capabilities.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index ccbe63c..955d119 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -3,8 +3,15 @@ // Spec: docs/DEVELOPMENT_PLAN.md §5 / §5a // M2: onboarding + REPL + slash commands + settings + permissions matcher. -import { CredentialsStore, VERSION, diagnoseSettings, redact } from '@deepcode/core'; -import { runAppServer } from '@deepcode/app-server'; +import { + CredentialsStore, + VERSION, + diagnoseSettings, + fileContractWarnings, + loadFileContract, + redact, +} from '@deepcode/core'; +import { capabilitiesFor, runAppServer } from '@deepcode/app-server'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { runDiagnosticsCommand } from './diagnostics-cmd.js'; @@ -267,6 +274,23 @@ async function doctor(): Promise { process.stdout.write(`Configuration error: ${(error as Error).message}\n`); failed = true; } + // What this runtime may actually do, from the same builder the app-server + // uses — so `doctor` cannot describe a posture the runtime does not have. + try { + const home = process.env.DEEPCODE_HOME ?? resolve(homedir(), '.deepcode'); + const caps = await capabilitiesFor(cwd, home); + process.stdout.write(`Sandbox: ${caps.sandbox.mode}\n`); + process.stdout.write(`Write scope: ${caps.writeScope.join(', ') || '(nothing writable)'}\n`); + process.stdout.write(`File contract: ${caps.permissions.fileContract}\n`); + process.stdout.write(`Always confirmed: ${caps.confirmationRequired.join(', ')}\n`); + process.stdout.write(`Ledger: ${caps.ledger.enabled ? caps.ledger.path : 'disabled'}\n`); + const contract = await loadFileContract({ cwd, directory: home }); + for (const warning of fileContractWarnings({ ...contract, sandboxMode: caps.sandbox.mode })) { + process.stdout.write(`Warning: ${warning}\n`); + } + } catch (error) { + process.stdout.write(`Capabilities error: ${(error as Error).message}\n`); + } return failed ? 1 : 0; } diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index ee4f256..805a9d4 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -29,6 +29,7 @@ class FakeTransport implements ProtocolTransport { reviewActions: true, reasoningDeltas: true, threadManagement: true, + runtimeCapabilities: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index d7b60cf..d39d8e9 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -24,6 +24,7 @@ const capabilities: InitializeResult = { reviewActions: true, reasoningDeltas: true, threadManagement: true, + runtimeCapabilities: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, diff --git a/apps/server/src/capabilities.test.ts b/apps/server/src/capabilities.test.ts new file mode 100644 index 0000000..a189ba9 --- /dev/null +++ b/apps/server/src/capabilities.test.ts @@ -0,0 +1,125 @@ +import { RuntimeHost } from '@deepcode/core'; +import { ToolRegistry } from '@deepcode/core'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Provider, ProviderResult } from '@deepcode/core'; +import { capabilitiesFor } from './capabilities.js'; + +const nullProvider: Provider = { + name: 'null', + async runTurn(): Promise { + throw new Error('not used'); + }, +}; + +/** + * The alignment plan's P0 is that permissions and tool execution are not a + * unified runtime capability — different hosts resolve the same settings + * differently. This suite is the executable form of that claim: if the CLI and + * the app-server ever disagree about what the runtime may do, it fails here + * rather than in someone's workspace. + * + * VS Code and the LSP are thin protocol clients over the app-server, so they + * receive the server's answer verbatim and are equal by construction. + */ +describe('runtime capabilities agree across hosts', () => { + let cwd: string; + let home: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-caps-cwd-')); + home = await mkdtemp(join(tmpdir(), 'dc-caps-home-')); + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + }); + + async function writeSettings(settings: Record): Promise { + await mkdir(join(home), { recursive: true }); + await writeFile(join(home, 'settings.json'), JSON.stringify(settings), 'utf8'); + } + + /** The CLI path: RuntimeHost resolves policy itself. */ + async function cliCapabilities(settings: { + mode?: string; + permissions?: Record; + sandbox?: Record; + }) { + const host = new RuntimeHost({ + provider: nullProvider, + tools: new ToolRegistry([]), + cwd, + home, + mode: (settings.mode ?? 'default') as never, + permissions: settings.permissions as never, + sandboxConfig: settings.sandbox as never, + }); + return host.capabilities(cwd); + } + + it('agree on a default configuration', async () => { + await writeSettings({}); + const server = await capabilitiesFor(cwd, home); + const cli = await cliCapabilities({}); + expect(server.sandbox).toEqual(cli.sandbox); + expect(server.writeScope).toEqual(cli.writeScope); + expect(server.confirmationRequired).toEqual(cli.confirmationRequired); + expect(server.permissions.fileContract).toEqual(cli.permissions.fileContract); + }); + + it('agree that the sandbox is off when settings disable it', async () => { + const sandbox = { mode: 'danger-full-access' }; + await writeSettings({ sandbox }); + const server = await capabilitiesFor(cwd, home); + const cli = await cliCapabilities({ sandbox }); + expect(server.sandbox).toEqual({ mode: 'danger-full-access', effective: false }); + expect(server.sandbox).toEqual(cli.sandbox); + expect(server.writeScope).toEqual(cli.writeScope); + }); + + it('agree on rule counts', async () => { + const permissions = { allow: ['Read', 'Grep'], deny: ['Bash'] }; + await writeSettings({ permissions }); + const server = await capabilitiesFor(cwd, home); + const cli = await cliCapabilities({ permissions }); + expect(server.permissions.ruleCounts).toEqual({ allow: 2, ask: 0, deny: 1 }); + expect(server.permissions.ruleCounts).toEqual(cli.permissions.ruleCounts); + }); + + it('agree that a contract is loaded', async () => { + await writeSettings({}); + await mkdir(join(cwd, '.deepcode'), { recursive: true }); + await writeFile( + join(cwd, '.deepcode', 'file-contract.yaml'), + 'version: 1\nrules:\n - glob: "**/.env*"\n read: deny\n', + ); + const server = await capabilitiesFor(cwd, home); + const cli = await cliCapabilities({}); + expect(server.permissions.fileContract).toBe('loaded'); + expect(server.permissions.fileContract).toBe(cli.permissions.fileContract); + }); + + it('agree that a malformed contract is invalid, not absent', async () => { + await writeSettings({}); + await mkdir(join(cwd, '.deepcode'), { recursive: true }); + await writeFile( + join(cwd, '.deepcode', 'file-contract.yaml'), + 'version: 1\nrules:\n - glob: "a"\n read: maybe\n', + ); + const server = await capabilitiesFor(cwd, home); + const cli = await cliCapabilities({}); + expect(server.permissions.fileContract).toBe('invalid'); + expect(server.permissions.fileContract).toBe(cli.permissions.fileContract); + }); + + it('point at the same ledger file', async () => { + await writeSettings({}); + const server = await capabilitiesFor(cwd, home); + const cli = await cliCapabilities({}); + expect(server.ledger.path).toBe(cli.ledger.path); + expect(server.ledger.enabled).toBe(true); + }); +}); diff --git a/apps/server/src/capabilities.ts b/apps/server/src/capabilities.ts new file mode 100644 index 0000000..3b51509 --- /dev/null +++ b/apps/server/src/capabilities.ts @@ -0,0 +1,44 @@ +// Server-side answer to `runtime/capabilities`. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.C +// +// Resolves the same inputs the CLI resolves and hands them to the same builder +// in `@deepcode/core`. Shaping the object here instead would be the exact drift +// this method exists to make visible. + +import { + buildRuntimeCapabilities, + ledgerPath, + loadFileContract, + loadSettings, + withAdditionalWritableDirs, + type Mode, +} from '@deepcode/core'; +import type { RuntimeCapabilitiesResult } from '@deepcode/protocol'; + +export async function capabilitiesFor( + cwd: string, + home: string, +): Promise { + const { merged } = await loadSettings({ cwd, directory: home }); + const contract = await loadFileContract({ cwd, directory: home }); + + return buildRuntimeCapabilities({ + cwd, + mode: (merged.permissions?.defaultMode ?? 'default') as Mode, + permissions: merged.permissions, + sandboxConfig: withAdditionalWritableDirs( + merged.sandbox, + merged.permissions?.additionalDirectories, + cwd, + ), + sandboxDefaultMode: 'workspace-write', + fileContract: contract.status, + ledger: { enabled: true, path: ledgerPath(cwd, 'changes', home) }, + modules: { + hooks: !!merged.hooks, + plugins: merged.plugins?.globalEnabled !== false, + ledger: true, + fileContract: contract.status === 'loaded', + }, + }); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 5c73e70..7e39c22 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,3 +9,4 @@ export * from './runtime-composition.js'; export * from './structured-logger.js'; export * from './diagnostic-export.js'; export * from './workspace-diff.js'; +export { capabilitiesFor } from './capabilities.js'; diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts index b01a791..2ee8387 100644 --- a/apps/server/src/run.ts +++ b/apps/server/src/run.ts @@ -4,6 +4,7 @@ import type { Readable, Writable } from 'node:stream'; import type { ProtocolNotification } from '@deepcode/protocol'; import { diagnoseSettings, DirectoryTrustStore } from '@deepcode/core/config'; +import { capabilitiesFor } from './capabilities.js'; import { createDefaultTurnExecutor } from './default-runtime.js'; import { AppServer, type TurnExecutor } from './server.js'; import { CanonicalThreadStore } from './store.js'; @@ -41,6 +42,7 @@ export async function runAppServer(options: RunAppServerOptions): Promise join(options.home, 'sessions'), ), configDiagnostics: diagnosticsFor, + runtimeCapabilities: (cwd) => capabilitiesFor(cwd, options.home), diagnosticExport: async (cwd) => { await logger.flush(); return exportDiagnosticBundle({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8498eb8..3223fe9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -7,6 +7,7 @@ import { reviewRevertPrompt, type CompletedItemType, type ConfigDiagnosticsResult, + type RuntimeCapabilitiesResult, type DiagnosticExportResult, type ProtocolEvent, type ProtocolRequest, @@ -66,6 +67,7 @@ export interface AppServerOptions { onEvent?: (event: ProtocolEvent) => void; onTrace?: (record: AppServerTraceRecord) => void; configDiagnostics?: (cwd: string) => Promise; + runtimeCapabilities?: (cwd: string) => Promise; diagnosticExport?: (cwd: string) => Promise; workspaceDiff?: (cwd: string) => Promise; } @@ -125,6 +127,7 @@ export class AppServer { newTraceId: this.newTraceId, onEvent: options.onEvent, configDiagnostics: options.configDiagnostics !== undefined, + runtimeCapabilities: options.runtimeCapabilities !== undefined, diagnosticExport: options.diagnosticExport !== undefined, workspaceDiff: options.workspaceDiff !== undefined, reviewActions: true, @@ -198,6 +201,11 @@ export class AppServer { switch (request.method) { case 'initialize': return this.lifecycle.initialize(); + case 'runtime/capabilities': + if (!this.options.runtimeCapabilities) { + throw new RequestValidationError('Runtime capabilities are not available'); + } + return this.options.runtimeCapabilities(requiredString(request.params, 'cwd')); case 'config/diagnostics': if (!this.options.configDiagnostics) { throw new RequestValidationError('Configuration diagnostics are not available'); diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index 69d508a..76f7148 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -44,6 +44,7 @@ class FakeClient { reviewActions: true, reasoningDeltas: true, threadManagement: true, + runtimeCapabilities: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, diff --git a/docs/design/app-server-v1.md b/docs/design/app-server-v1.md index fa80147..047d57d 100644 --- a/docs/design/app-server-v1.md +++ b/docs/design/app-server-v1.md @@ -33,21 +33,22 @@ by expecting partial deltas to replay. ## Methods -| Method | Required parameters | Result | -| -------------------- | ------------------------------- | -------------------------------------------------- | -| `initialize` | none | version and capabilities | -| `thread/start` | `cwd` | new thread snapshot | -| `thread/read` | `threadId` | thread snapshot or null | -| `thread/resume` | `threadId` | resumable snapshot | -| `turn/start` | `threadId`, object `input` | in-progress turn snapshot | -| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race | -| `approval/respond` | thread, turn, request, decision | whether the pending request accepted the response | -| `user-input/respond` | thread, turn, request, answer | whether the pending request accepted the response | -| `config/diagnostics` | workspace cwd | value-free layers, provenance, trust gates, issues | -| `diagnostics/export` | workspace cwd | redacted local diagnostic bundle metadata | -| `workspace/diff` | `threadId` | bounded structured workspace diff | -| `review/apply` | `threadId`, `findingIds` | permission-gated review action turn | -| `review/revert` | `threadId`, `actionId` | conflict-safe restore action turn | +| Method | Required parameters | Result | +| ---------------------- | ------------------------------- | ------------------------------------------------------------------- | +| `initialize` | none | version and capabilities | +| `thread/start` | `cwd` | new thread snapshot | +| `thread/read` | `threadId` | thread snapshot or null | +| `thread/resume` | `threadId` | resumable snapshot | +| `turn/start` | `threadId`, object `input` | in-progress turn snapshot | +| `turn/interrupt` | `threadId`, `turnId` | whether interruption won the state race | +| `approval/respond` | thread, turn, request, decision | whether the pending request accepted the response | +| `user-input/respond` | thread, turn, request, answer | whether the pending request accepted the response | +| `runtime/capabilities` | workspace cwd | write scope, always-confirmed actions, sandbox and contract posture | +| `config/diagnostics` | workspace cwd | value-free layers, provenance, trust gates, issues | +| `diagnostics/export` | workspace cwd | redacted local diagnostic bundle metadata | +| `workspace/diff` | `threadId` | bounded structured workspace diff | +| `review/apply` | `threadId`, `findingIds` | permission-gated review action turn | +| `review/revert` | `threadId`, `actionId` | conflict-safe restore action turn | `turn/start` returns before model work finishes. The server emits transient deltas while the turn runs, then persists new provider-history messages as completed items before emitting exactly one @@ -134,3 +135,50 @@ closes stdin first so the server can interrupt and persist active turns before a - thread listing, archive, fork, and search; - multi-client subscriptions or active-turn attachment; + +## `runtime/capabilities` vs `initialize` + +Both return something called capabilities, and the distinction is load-bearing: + +- `initialize` answers **which protocol methods work** — `threadResume`, + `workspaceDiff`, `reviewActions`. Flags about this server's feature set. +- `runtime/capabilities` answers **what the runtime is allowed to do to the + machine** — where it may write, which actions always stop for a human, whether + the sandbox is actually in effect, whether a file contract is loaded. + +A client that wants to warn "this runtime can write anywhere" needs the second, +and no amount of feature flags substitutes for it. + +Keeping them apart is also what stops the next field from landing in the wrong +one. If a field describes the server's _implementation_, it belongs in +`initialize`; if it describes the _authority_ the runtime holds, it belongs here. + +```json +{ + "writeScope": ["/work/repo"], + "confirmationRequired": ["ledger.rollback", "plugin.install", "contract.change", "trust.grant"], + "sandbox": { "mode": "workspace-write", "effective": true }, + "permissions": { + "mode": "default", + "fileContract": "loaded", + "ruleCounts": { "allow": 2, "ask": 0, "deny": 1 } + }, + "ledger": { "enabled": true, "path": "~/.deepcode/projects/-work-repo/ledger/changes.jsonl" }, + "modules": { "hooks": "enabled", "plugins": "disabled" } +} +``` + +Two deliberate choices in that payload: + +- **`writeScope` reports `[""]`** under + `danger-full-access`, not `[]`. An empty array reads as "writes nowhere", + which is the exact opposite of the truth. +- **Permission rules are reported as counts, not contents.** The rules can hold + user paths; a count answers "is anything configured" without handing them to + every client that asks. + +The CLI and the app-server both build this through +`buildRuntimeCapabilities` in `@deepcode/core`, and a test in +`apps/server/src/capabilities.test.ts` asserts they agree field-for-field. VS +Code and the LSP are thin protocol clients, so they receive the server's answer +verbatim. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9d535ca..c4292b3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -554,3 +554,11 @@ export { type ApplyPresentation, } from './runtime/apply-ceremony.js'; export { planRollback, type RollbackContext, type RollbackPlanResult } from './ledger/rollback.js'; + +// Runtime capability declaration (plan §2.C) +export { + ALWAYS_CONFIRMED_ACTIONS, + buildRuntimeCapabilities, + type BuildRuntimeCapabilitiesInput, + type RuntimeCapabilities, +} from './runtime/capabilities.js'; diff --git a/packages/core/src/runtime/capabilities.test.ts b/packages/core/src/runtime/capabilities.test.ts new file mode 100644 index 0000000..be25569 --- /dev/null +++ b/packages/core/src/runtime/capabilities.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + ALWAYS_CONFIRMED_ACTIONS, + buildRuntimeCapabilities, + type BuildRuntimeCapabilitiesInput, +} from './capabilities.js'; + +function input( + overrides: Partial = {}, +): BuildRuntimeCapabilitiesInput { + return { + cwd: '/work/repo', + mode: 'default', + fileContract: 'absent', + ...overrides, + }; +} + +describe('buildRuntimeCapabilities', () => { + it('is pure — the same input always produces the same object', () => { + // The equality between hosts is the whole point of this method, so it has + // to hold structurally rather than by convention. + expect(buildRuntimeCapabilities(input())).toEqual(buildRuntimeCapabilities(input())); + }); + + it('reports the workspace as writable under workspace-write', () => { + const caps = buildRuntimeCapabilities(input({ sandboxConfig: { mode: 'workspace-write' } })); + expect(caps.writeScope).toContain('/work/repo'); + expect(caps.sandbox).toEqual({ mode: 'workspace-write', effective: true }); + }); + + it('includes explicitly allowed write paths', () => { + const caps = buildRuntimeCapabilities( + input({ + sandboxConfig: { mode: 'workspace-write', filesystem: { allowWrite: ['/tmp/build'] } }, + }), + ); + expect(caps.writeScope).toEqual(['/work/repo', '/tmp/build']); + }); + + it('reports nothing writable under read-only', () => { + const caps = buildRuntimeCapabilities(input({ sandboxConfig: { mode: 'read-only' } })); + expect(caps.writeScope).toEqual([]); + expect(caps.sandbox.effective).toBe(true); + }); + + it('says "everything" rather than nothing when the sandbox is off', () => { + // An empty writeScope reads as "writes nowhere" — the exact opposite of + // the truth, and the most dangerous thing this declaration could get wrong. + const caps = buildRuntimeCapabilities(input({ sandboxConfig: { mode: 'danger-full-access' } })); + expect(caps.writeScope).toEqual(['']); + expect(caps.sandbox.effective).toBe(false); + }); + + it('treats an unconfigured sandbox as the host default', () => { + expect(buildRuntimeCapabilities(input()).sandbox.mode).toBe('workspace-write'); + expect( + buildRuntimeCapabilities(input({ sandboxDefaultMode: 'danger-full-access' })).sandbox.mode, + ).toBe('danger-full-access'); + }); + + it('always lists the confirmation-required actions', () => { + expect(buildRuntimeCapabilities(input()).confirmationRequired).toEqual([ + ...ALWAYS_CONFIRMED_ACTIONS, + ]); + }); + + it('reports rule counts, not the rules themselves', () => { + // The rules can contain user paths; a count answers "is anything + // configured" without handing them to every client that asks. + const caps = buildRuntimeCapabilities( + input({ permissions: { allow: ['Read', 'Grep'], deny: ['Bash'] } }), + ); + expect(caps.permissions.ruleCounts).toEqual({ allow: 2, ask: 0, deny: 1 }); + expect(JSON.stringify(caps)).not.toContain('Grep'); + }); + + it('surfaces the contract status, including invalid', () => { + for (const status of ['absent', 'loaded', 'invalid'] as const) { + expect( + buildRuntimeCapabilities(input({ fileContract: status })).permissions.fileContract, + ).toBe(status); + } + }); + + it('maps modules to enabled/disabled', () => { + const caps = buildRuntimeCapabilities(input({ modules: { hooks: true, plugins: false } })); + expect(caps.modules).toEqual({ hooks: 'enabled', plugins: 'disabled' }); + }); + + it('reports no ledger path when the ledger is off', () => { + expect(buildRuntimeCapabilities(input()).ledger).toEqual({ enabled: false, path: '' }); + }); +}); diff --git a/packages/core/src/runtime/capabilities.ts b/packages/core/src/runtime/capabilities.ts new file mode 100644 index 0000000..db35553 --- /dev/null +++ b/packages/core/src/runtime/capabilities.ts @@ -0,0 +1,121 @@ +// Runtime capability declaration — what this runtime may write, and which +// actions always need a human. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.C +// +// `initialize()` already reports capabilities, but they are all *protocol* +// features (threadResume, workspaceDiff, …). No client can ask the question +// that actually matters before it starts trusting a runtime: where will this +// thing write, and what will it stop to ask me about? +// +// This is that answer, built by ONE function so the CLI and the app-server +// cannot drift — which is also what makes the alignment plan's P0 ("permission +// and tool execution are not a unified runtime capability") testable rather +// than aspirational. + +import { resolveSandboxMode } from '../sandbox/policy.js'; +import type { PermissionRules, SandboxConfig, SandboxMode } from '../config/types.js'; +import type { FileContractStatus } from '../config/file-contract-loader.js'; +import type { Mode } from '../types.js'; + +/** + * Actions that require explicit user confirmation regardless of permission + * mode, mirroring Selfware's `confirmation_required`. + * + * These are the operations wired through the No Silent Apply ceremony. Listing + * them in the declaration means a client can render "this runtime will always + * stop and ask before X" without having to know the implementation. + */ +export const ALWAYS_CONFIRMED_ACTIONS = Object.freeze([ + 'ledger.rollback', + 'plugin.install', + 'contract.change', + 'trust.grant', +] as const); + +export interface RuntimeCapabilities { + /** Absolute paths the runtime may write to under the current sandbox. */ + writeScope: string[]; + /** Actions that always need confirmation, whatever the permission mode. */ + confirmationRequired: string[]; + sandbox: { + mode: SandboxMode; + /** False when the mode is `danger-full-access` — no OS-level bound at all. */ + effective: boolean; + }; + permissions: { + mode: Mode; + /** Whether a path-axis contract is loaded, absent, or unparseable. */ + fileContract: FileContractStatus; + /** Tool-rule counts; the rules themselves can hold user paths. */ + ruleCounts: { allow: number; ask: number; deny: number }; + }; + ledger: { + enabled: boolean; + /** Where records are written; empty when disabled. */ + path: string; + }; + /** Optional subsystems and whether they are on for this runtime. */ + modules: Record; +} + +export interface BuildRuntimeCapabilitiesInput { + cwd: string; + mode: Mode; + permissions?: PermissionRules; + sandboxConfig?: SandboxConfig; + sandboxDefaultMode?: SandboxMode; + fileContract: FileContractStatus; + ledger?: { enabled: boolean; path: string }; + modules?: Record; +} + +/** + * Build the declaration. Pure, so two hosts given the same settings produce + * byte-identical output — that equality is the point, and it is asserted in + * tests rather than assumed. + */ +export function buildRuntimeCapabilities( + input: BuildRuntimeCapabilitiesInput, +): RuntimeCapabilities { + const mode = resolveSandboxMode( + input.sandboxConfig, + input.sandboxDefaultMode ?? 'workspace-write', + ); + const rules = input.permissions ?? {}; + + return { + writeScope: writeScopeFor(mode, input.cwd, input.sandboxConfig), + confirmationRequired: [...ALWAYS_CONFIRMED_ACTIONS], + sandbox: { mode, effective: mode !== 'danger-full-access' }, + permissions: { + mode: input.mode, + fileContract: input.fileContract, + ruleCounts: { + allow: rules.allow?.length ?? 0, + ask: rules.ask?.length ?? 0, + deny: rules.deny?.length ?? 0, + }, + }, + ledger: input.ledger ?? { enabled: false, path: '' }, + modules: Object.fromEntries( + Object.entries(input.modules ?? {}).map(([name, on]) => [name, on ? 'enabled' : 'disabled']), + ), + }; +} + +/** + * The writable set, as a client should understand it. + * + * `danger-full-access` reports `['']` rather than an empty list. + * An empty array reads as "writes nowhere", which is the exact opposite of the + * truth and the most dangerous thing this declaration could get wrong. + */ +function writeScopeFor( + mode: SandboxMode, + cwd: string, + config: SandboxConfig | undefined, +): string[] { + if (mode === 'danger-full-access') return ['']; + if (mode === 'read-only') return []; + return [cwd, ...(config?.filesystem?.allowWrite ?? [])]; +} diff --git a/packages/core/src/runtime/host.ts b/packages/core/src/runtime/host.ts index aa720be..bd42e89 100644 --- a/packages/core/src/runtime/host.ts +++ b/packages/core/src/runtime/host.ts @@ -12,7 +12,8 @@ import type { } from '../config/types.js'; import { fileContractWarnings } from '../config/contract-dispatch.js'; import { loadFileContract, type LoadedFileContract } from '../config/file-contract-loader.js'; -import { FileLedger, type LedgerSink } from '../ledger/index.js'; +import { FileLedger, ledgerPath, type LedgerSink } from '../ledger/index.js'; +import { buildRuntimeCapabilities, type RuntimeCapabilities } from './capabilities.js'; import { resolveSandboxMode } from '../sandbox/policy.js'; import type { HookDispatcher } from '../hooks/index.js'; import type { Provider } from '../providers/types.js'; @@ -127,6 +128,36 @@ export class RuntimeHost { return ledger; } + /** + * What this runtime may write and what it will always stop to ask about. + * + * Built through the shared builder so a client asking the CLI and a client + * asking the app-server get the same answer for the same settings. + */ + async capabilities(cwd?: string): Promise { + const dir = cwd ?? this.options.cwd ?? process.cwd(); + const contract = await this.fileContract(dir); + const ledgerEnabled = !this.options.disableLedger; + return buildRuntimeCapabilities({ + cwd: dir, + mode: this.mode, + permissions: this.permissions, + sandboxConfig: this.options.sandboxConfig, + sandboxDefaultMode: this.options.sandboxDefaultMode ?? 'workspace-write', + fileContract: contract.status, + ledger: { + enabled: ledgerEnabled, + path: ledgerEnabled ? ledgerPath(dir, 'changes', this.options.home) : '', + }, + modules: { + hooks: !!this.options.hooks, + plugins: (this.options.pluginDirs?.length ?? 0) > 0, + ledger: ledgerEnabled, + fileContract: contract.status === 'loaded', + }, + }); + } + /** * Operator warnings about how far the contract actually reaches, for hosts to * print at startup and for `deepcode doctor`. diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index c803ad7..ecd7340 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -10,3 +10,9 @@ export { type RuntimeHostOptions, type RuntimeTurnOptions, } from './host.js'; +export { + ALWAYS_CONFIRMED_ACTIONS, + buildRuntimeCapabilities, + type BuildRuntimeCapabilitiesInput, + type RuntimeCapabilities, +} from './capabilities.js'; diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts index 4009beb..e8efcbb 100644 --- a/packages/protocol/src/runtime.test.ts +++ b/packages/protocol/src/runtime.test.ts @@ -36,6 +36,7 @@ describe('ProtocolRuntime', () => { structuredToolEvents: true, interactiveRequests: true, reviewActions: false, + runtimeCapabilities: false, reasoningDeltas: false, threadManagement: true, configDiagnostics: false, diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index f829a11..df52081 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -60,6 +60,7 @@ export interface ProtocolRuntimeOptions { newTraceId?: () => string; onEvent?: (event: ProtocolEvent) => void; configDiagnostics?: boolean; + runtimeCapabilities?: boolean; diagnosticExport?: boolean; workspaceDiff?: boolean; reviewActions?: boolean; @@ -117,6 +118,7 @@ export class ProtocolRuntime { structuredToolEvents: true, interactiveRequests: true, configDiagnostics: this.options.configDiagnostics ?? false, + runtimeCapabilities: this.options.runtimeCapabilities ?? false, diagnosticExport: this.options.diagnosticExport ?? false, workspaceDiff: this.options.workspaceDiff ?? false, reviewActions: this.options.reviewActions ?? false, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index a3565eb..9a6922d 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -163,7 +163,34 @@ export interface InitializeResult { * desktop ended up with two ways to see the same threads. */ threadManagement: boolean; + /** + * `runtime/capabilities` is served. Distinct from the flags above: those + * say which protocol features exist, this one says what the runtime will + * write and what it always stops to ask about. + */ + runtimeCapabilities: boolean; + }; +} + +/** + * What the runtime may write, and which actions always need a human. + * + * Deliberately not folded into `InitializeResult.capabilities`. That object + * answers "which protocol methods work"; this one answers "what is this + * runtime allowed to do to my machine". Keeping them apart is what stops the + * next field from landing in the wrong one. + */ +export interface RuntimeCapabilitiesResult { + writeScope: string[]; + confirmationRequired: string[]; + sandbox: { mode: string; effective: boolean }; + permissions: { + mode: string; + fileContract: 'absent' | 'loaded' | 'invalid'; + ruleCounts: { allow: number; ask: number; deny: number }; }; + ledger: { enabled: boolean; path: string }; + modules: Record; } export type ConfigLayerName = 'user' | 'project' | 'local' | 'override'; @@ -265,6 +292,7 @@ export interface ThreadListResult { export type ProtocolMethod = | 'initialize' + | 'runtime/capabilities' | 'config/diagnostics' | 'diagnostics/export' | 'workspace/diff'