Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion docs/adr/0029-host-adapter-extension-point.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,45 @@ names:
An adapter manifest declaring `acp` or `mcp` today fails admission with an explicit "surface not yet
supported" diagnostic. It is never silently downgraded to `cli-subprocess` — a silent downgrade
would run a hook the adapter author never tested against that surface, exactly the kind of guessed
success ADR-0016 §5 and ADR-0023 already forbid elsewhere.
success ADR-0016 §5 and ADR-0023 already forbid elsewhere. An admitted host is given a
`cli-subprocess` **execution** adapter only when it declares that surface; a manifest missing it is
refused (`surface-unsupported`) rather than downgraded.

**`cli-subprocess` execution details** (settled while wiring [ADR-0031](0031-capability-graduation-and-upstream-requests.md)'s
external-execution row, after an adversarial review of the surface):

- **Command resolution is anchored, not ambient.** A hook subprocess runs with its working
directory pinned to the adapter's own resolved directory (a file-sourced manifest's `realpath`
directory), never `ak`'s current working directory — so a manifest declaring
`["node", "run-hook.mjs"]` runs *the adapter's* `run-hook.mjs`, and a file planted in the
operator's cwd is unreachable. A remote-sourced manifest (`npm:`/`https://`) has no persistent
local directory, so a *relative* hook command from such a source is refused
(`execution-unanchored`) rather than resolved against an ambient path; a bare PATH binary
(`node`, `hermes`) stays legal. The consent hash still pins the manifest text verbatim; the
resolution is a pure function of that text plus the (already-pinned) source, so it cannot drift
without the hash changing.
- *Boundary of the anchorability check for remote sources.* When a remote-sourced adapter has no
local directory to anchor to, its hook command spawns in the repository `ak run` was invoked in
(which the operator already runs at full trust, per ADR-0018), and the `execution-unanchored`
refusal is a **best-effort** screen for path-shaped tokens (separators, script extensions, flag
values), not a complete one: an *extensionless, separator-free* relative token
(`["node", "runhook"]`) is indistinguishable by inspection from an ordinary positional argument
(`["hermes-run", "build"]`), so it is not refused and would resolve against the repo. A complete
rule would have to reject every non-absolute, non-flag argument, which would also reject
legitimate positional arguments — a false-positive cost this contract does not pay by default.
The exposure is bounded on every axis that matters: it requires a remote (`npm:`/`https://`)
source, a consented manifest the operator hash-pinned with that exact relative token, and write
access to the operator's repo. A **file-sourced** adapter — the fixture, and every adapter that
ships a bundle — is fully anchored and unaffected. A remote-sourced adapter should declare
absolute paths or PATH binaries; a future contract revision may make that a hard requirement.
- **Reserved exit codes carry consent/auth boundaries.** Hook exit `77` maps to
`permission_required` (a blocked, never-escalated result — escalating around a consent boundary
is the safety violation ADR-0019 already forbids) and `78` to `auth_required`. This gives an
external host an honest way to say "I refused" or "I am not logged in" instead of a bare
non-zero exit that would be re-run on another host.
- **Results never launder trust.** Exit code is the sole authority for success; a self-declared
`provider` in hook stdout is stamped `inferred`, never `observed`; stderr is never promoted into
a downstream worker's prompt; and handoff data is redacted from public `WorkerResult`s.

### 3. Capability caps are schema-structural, not runtime-checked

Expand Down
4 changes: 2 additions & 2 deletions docs/adr/0031-capability-graduation-and-upstream-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ Per the ADR discipline this repository adopted (a dated, self-graded table befor
rests on delivery): the **governance decision** is accepted; the **machinery** is staged and mostly
unbuilt. This table is the source of truth for what is real.

| Piece | Status (2026-08-16) | Note |
| Piece | Status | Note |
| ----- | ------------------- | ---- |
| Admission gate, consent store, hook runner, conformance kit (`admission` tier) | **Working** | ADR-0029, merged (PR #149) |
| `ak host adapters trust` CLI (records consent/grants) | **Working** (2026-08-16, wave A) | `list`/`trust`/`revoke` + `--expect-hash` pinning; disclosure prints the full validated manifest (control-char-safe); mirrors every pre-hash admission refusal; `revoke` works with the flag off (fail-safe) |
| External execution (`ak run` drives an admitted host) | **Proposed — not built** | The seam is a comment-only lookup today |
| External execution (`ak run` drives an admitted host) | **Working** (2026-08-16, wave B) | Manifest `execution.run` hook (coupled to `canRouteActivities`, else refused `execution-not-routable`); derived subprocess adapter behind `executionAdapterFor`; routing is overlay-aware via a lazy `effectiveRoutableHostIds()`. Security-hardened (adversarial review): hooks spawn with `cwd` pinned to the adapter's own resolved directory (never the operator's cwd — a relative hook on a remote source is refused `execution-unanchored`); an unresolved-launch cancellation reports `orphaned` (non-escalating), never an escalatable `timed_out`; handoff data is redacted from public results; stderr is never promoted into a downstream prompt; reserved hook exit codes `77`/`78` express `permission_required`/`auth_required` boundaries; a self-declared `provider` is stamped `inferred`, never `observed` |
| External lifecycle execution wired into setup/sync/uninstall | **Proposed — not built** | Loops are built-in-scoped by design until generalized |
| Tiered conformance harness (`session-driving` … `statusline`) | **Proposed — not built** | Extends the single conformance kit |
| Capability-grant store + promotion command | **Partial** (2026-08-16, wave A) | Data layer working (`grants.mjs`): hash-pinned, evidence-gated (grant-bearing tiers require non-empty evidence), edit-invalidated like consent; promotion command pending a later wave |
Expand Down
66 changes: 66 additions & 0 deletions src/lib/adapters/admission.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,36 @@
// entry or the built-in registries (try/caught per entry, in admitOne AND as
// a belt-and-suspenders net in admitAdapters).
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { HOST_REGISTRY } from './registries.mjs';
import { validateAdapterManifest } from './manifest.mjs';

export const SUPPORTED_CONTRACT = 1;

/** The adapter's own directory (F-1, ADR-0031): where its execution/lifecycle
* hooks resolve a relative command FROM, never the operator's process.cwd()
* when `ak run` was invoked. A file-sourced manifest anchors to its own
* directory — `fs.realpathSync` so a symlinked manifest can't relocate that
* pin out from under consent. An npm/https source has no persistent local
* bundle (resolved, hashed, and discarded per admission pass — sources.mjs)
* so there is nothing to anchor to: `null`. buildAdmittedExecutionAdapter
* (execution/admitted.mjs) then refuses a relative hook command outright
* for a `null` baseDir rather than guessing a cwd. An unreadable/vanished
* file source also resolves to `null` — the same honest refusal, not a
* silent fallback to process.cwd(). */
function baseDirForSource(source) {
if (typeof source !== 'string' || !source
|| source.startsWith('https://') || source.startsWith('http://') || source.startsWith('npm:')) {
return null;
}
try {
return path.dirname(fs.realpathSync(source));
} catch {
return null;
}
}

/** Deterministic, key-sorted JSON — same stable-stringify shape used
* elsewhere in this codebase (e.g. opencode.mjs's deepEqual) so two manifests
* that differ only in key order or incidental whitespace hash identically.
Expand Down Expand Up @@ -193,6 +218,47 @@ export async function bootstrapHostAdapters({
if (admitted.length) {
const { applyAdmitted } = await import('./admitted.mjs');
applyAdmitted(admitted);

// P2 (ADR-0031): an admitted manifest declaring both an execution block
// and host.capabilities.canRouteActivities gets its execution adapter
// derived and registered here, so `ak run` can route to it. Same
// guarded, non-fatal posture as the rest of bootstrap: one adapter's
// registration failure never blocks the others or the admission result.
const executionCandidates = admitted.filter((result) => (
result.manifest?.execution && result.entry?.capabilities?.canRouteActivities === true
));
if (executionCandidates.length) {
// name -> the cfg entry's own declared source, for F-1's baseDir
// derivation below (admitted results carry the validated manifest, not
// the raw cfg entry that named where it came from).
const sourceByName = new Map(entries.map((entry) => [entry?.name, entry?.source]));
try {
const { registerAdmittedExecution } = await import('../execution/admitted.mjs');
for (const result of executionCandidates) {
// F-5 (ADR-0029 §2): a manifest that never declared the
// cli-subprocess driving surface gets no cli-subprocess execution
// adapter — refused with its own reason, before even attempting
// registration (buildAdmittedExecutionAdapter re-checks this too,
// defence-in-depth for any caller that bypasses this filter).
if (!result.manifest?.driving?.surfaces?.includes('cli-subprocess')) {
warnings.push({
name: result.name, reason: 'surface-unsupported',
detail: `'${result.name}' declares an execution block but not driving.surfaces including 'cli-subprocess'`,
});
continue;
}
try {
registerAdmittedExecution(result.manifest, { baseDir: baseDirForSource(sourceByName.get(result.name)) });
} catch (error) {
warnings.push({ name: result.name, reason: error?.reason ?? 'execution-registration-failed', detail: error?.message ?? String(error) });
}
}
} catch (error) {
for (const result of executionCandidates) {
warnings.push({ name: result.name, reason: 'execution-registration-failed', detail: error?.message ?? String(error) });
}
}
}
}

return { active: true, admitted, warnings };
Expand Down
12 changes: 12 additions & 0 deletions src/lib/adapters/admitted.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,15 @@ export function effectiveHostRegistry() {
if (!applied || admittedEntries.length === 0) return HOST_REGISTRY;
return Object.freeze([...HOST_REGISTRY, ...admittedEntries]);
}

/** Built-ins ∪ admitted hosts whose manifest declares
* capabilities.canRouteActivities — the LAZY set every routing VALIDATION
* path (routing.mjs's isRoutableHost, validateRoute, materializeRunPlan)
* must consult (P2, ADR-0031). routing.mjs's `HOSTS` constant stays frozen
* at import time and built-ins-only — it is display strings only now, never
* a validation source. Fresh on every call, like admittedHostIds() above. */
export function effectiveRoutableHostIds() {
return effectiveHostRegistry()
.filter((host) => host.capabilities.canRouteActivities === true)
.map((host) => host.id);
}
95 changes: 78 additions & 17 deletions src/lib/adapters/hook-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// summary capture, no graceful-then-forced two-step shutdown. A timed-out
// adapter hook gets no cleanup grace period; it already spent its budget.
import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process';
import { isAbsolute as pathIsAbsolute } from 'node:path';

const DEFAULT_TIMEOUT_MS = 30_000;
const OUTPUT_CAP_BYTES = 256 * 1024;
Expand Down Expand Up @@ -88,6 +89,15 @@ function mergeCapture(stdout, stderr) {
return `${kept}${TRUNCATION_MARKER}`;
}

/** stderr alone, with the same per-stream truncation marker `mergeCapture`
* would append — never folded into `stdout`. F-4: a caller that parses
* `stdout` as a structured payload (e.g. the admitted execution adapter)
* must never see raw stderr promoted into that parse; this is the field it
* reads instead when it needs the process's diagnostic chatter. */
function boundedText({ text, truncated }) {
return truncated ? `${text}${TRUNCATION_MARKER}` : text;
}

function describeFailure(hostId, verb, error) {
const reason = error?.code ? `${error.code} (${error.message ?? 'no message'})` : (error?.message ?? String(error));
return `${hostId}:${verb} adapter hook failed to start: ${reason}`;
Expand All @@ -104,7 +114,12 @@ function raceTimeout(promise, ms) {
* child — a timed-out adapter hook may have spawned descendants of its own.
* POSIX: the child was spawned detached so its pid is also its process group
* id; signalling `-pid` reaches the whole group. Windows has no portable
* signal for arbitrary console trees, so `taskkill /T /F` owns it there. */
* signal for arbitrary console trees, so `taskkill /T /F` owns it there.
* F-2: this cannot PROVE a double-forked or re-`setsid`'d grandchild died —
* a signal sent is not a death confirmed. Callers that need that proof (the
* admitted execution adapter's cancel path) must treat an unresolved launch
* as honestly unproven (`orphaned`), not assume this function's return means
* the tree is gone. */
async function killGroup(child) {
if (!Number.isInteger(child?.pid)) return;
if (isWindows) {
Expand All @@ -127,38 +142,71 @@ async function killGroup(child) {
* (missing/invalid `hook`, `hostId`, or `verb`) throw synchronously.
*
* @param {{hook:{command:string[], timeoutMs?:number}, hostId:string,
* verb:string, timeoutMs?:number, env?:Record<string,string>}} options
* @returns {Promise<{ok:boolean, stdout:string, exitCode:number|null, detail:string|null}>}
* verb:string, timeoutMs?:number, env?:Record<string,string>, stdin?:string,
* cwd?:string}} options
* @returns {Promise<{ok:boolean, stdout:string, stdoutText:string, stderrText:string,
* exitCode:number|null, detail:string|null}>}
*/
export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /** @type {any} */ ({})) {
export async function runAdapterHook({
hook, hostId, verb, timeoutMs, env, stdin, cwd,
} = /** @type {any} */ ({})) {
if (!hook || !Array.isArray(hook.command) || hook.command.length === 0
|| !hook.command.every((part) => typeof part === 'string' && part.length > 0)) {
throw new TypeError('runAdapterHook requires hook.command as a non-empty array of non-empty strings');
}
if (typeof hostId !== 'string' || !hostId) throw new TypeError('runAdapterHook requires a hostId');
if (typeof verb !== 'string' || !verb) throw new TypeError('runAdapterHook requires a verb');
// F-1: a relative cwd would resolve against wherever the ak process
// happens to be running, defeating the whole point of pinning the child to
// the adapter's own directory — refused synchronously, same class as the
// arg-shape checks above, never silently reinterpreted as "inherit".
if (cwd !== undefined && (typeof cwd !== 'string' || !cwd || !pathIsAbsolute(cwd))) {
throw new TypeError('runAdapterHook requires cwd to be an absolute path when provided');
}

const effectiveTimeoutMs = resolveTimeout(timeoutMs, hook.timeoutMs);
const [argv0, ...args] = hook.command;
const childEnv = minimalEnv(env);

const wantsStdin = typeof stdin === 'string';
let child;
try {
child = nodeSpawn(argv0, args, {
env: childEnv,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: [wantsStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
detached: !isWindows,
// Absent cwd falls through to Node's own default (inherit
// process.cwd()) — today's behavior for callers that don't pass one
// yet (B2 threads the real adapter-base-dir cwd through this wave).
...(cwd === undefined ? {} : { cwd }),
});
} catch (error) {
return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, error) };
return {
ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, error),
};
}

const stdoutCollector = boundedCollector(OUTPUT_CAP_BYTES);
const stderrCollector = boundedCollector(OUTPUT_CAP_BYTES);
child.stdout?.on('data', (chunk) => stdoutCollector.write(chunk));
child.stderr?.on('data', (chunk) => stderrCollector.write(chunk));

if (wantsStdin) {
// A child that exits before (or without) reading stdin makes the pipe
// write EPIPE — that is a normal outcome (the process's own exit code
// already reports what happened), never a reason to crash or reject
// runAdapterHook's promise. The 'close' handler below still fires and
// resolves the race normally regardless of whether this write lands.
child.stdin?.on('error', () => {});
try {
child.stdin?.end(stdin);
} catch {
// Synchronous throw from an already-closed stream — same non-fatal
// treatment as the async 'error' event above.
}
}

let settled = false;
let spawnError = null;
const closeResult = new Promise((resolve) => {
Expand All @@ -175,27 +223,40 @@ export async function runAdapterHook({ hook, hostId, verb, timeoutMs, env } = /*
if (raced === TIMEOUT_SENTINEL) {
await killGroup(child);
await raceTimeout(closeResult, KILL_GRACE_MS); // best-effort; result unused
const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() };
const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() };
return {
ok: false,
exitCode: null,
stdout: mergeCapture(
{ text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
{ text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
),
stdout: mergeCapture(stdoutCaptured, stderrCaptured),
stdoutText: boundedText(stdoutCaptured),
stderrText: boundedText(stderrCaptured),
detail: `${hostId}:${verb} adapter hook timed out after ${effectiveTimeoutMs}ms and was killed`,
};
}

if (spawnError) {
return { ok: false, stdout: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError) };
return {
ok: false, stdout: '', stdoutText: '', stderrText: '', exitCode: null, detail: describeFailure(hostId, verb, spawnError),
};
}

const { code } = raced;
const stdout = mergeCapture(
{ text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() },
{ text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() },
);
// F-4/R-1: stdout stays the combined stream for diagnostics/back-compat,
// but a caller parsing stdout as a structured payload (the admitted
// execution adapter) must read stdoutText instead — stdout has stderr
// folded in after a separator, which breaks JSON.parse the instant the
// hook writes anything to stderr at all. stderrText is diagnostics-only.
const stdoutCaptured = { text: stdoutCollector.text(), truncated: stdoutCollector.wasTruncated() };
const stderrCaptured = { text: stderrCollector.text(), truncated: stderrCollector.wasTruncated() };
const stdout = mergeCapture(stdoutCaptured, stderrCaptured);
const stdoutText = boundedText(stdoutCaptured);
const stderrText = boundedText(stderrCaptured);
return code === 0
? { ok: true, stdout, exitCode: 0, detail: null }
: { ok: false, stdout, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}` };
? {
ok: true, stdout, stdoutText, stderrText, exitCode: 0, detail: null,
}
: {
ok: false, stdout, stdoutText, stderrText, exitCode: code, detail: `${hostId}:${verb} adapter hook exited with code ${code}`,
};
}
Loading
Loading