import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { digest } from './task-controller.mjs'; const hash = bytes => createHash('sha256').update(bytes).digest('hex'); // A deliberately bounded local adapter, not a shell, remote executor, or approval issuer. // Call only after the real host has authorized approvedPlan. No input file authenticates itself. export function localFileHost({ root, approvedPlan, approvalReceipt, verifyHuman }) { if (!path.isAbsolute(root) || !fs.statSync(root).isDirectory() || fs.realpathSync(root) !== root || typeof approvalReceipt !== 'string' || !approvalReceipt.trim() || typeof verifyHuman !== 'function') { throw Error('TRUSTED_HOST_CONFIGURATION_REQUIRED'); } const approved = structuredClone(approvedPlan); const expected = digest(approved); const registeredGrants = new WeakSet(); for (const step of approved.steps) { if (step.capability !== 'CREATE_TEXT_FILE' || typeof step.args.text !== 'string' || typeof step.args.path !== 'string' || path.dirname(step.args.path) !== root || path.basename(step.args.path) === '.' || path.basename(step.args.path) === '..' || step.args.path !== path.resolve(step.args.path)) throw Error('LOCAL_CAPABILITY_OUT_OF_SCOPE'); } return { verifyHuman, async authorize({ plan, step, planHash, signal }) { signal.throwIfAborted(); if (digest(plan) !== expected || planHash !== expected || !approved.steps.some(s => digest(s) === digest(step))) throw Error('PLAN_NOT_AUTHORIZED'); const grant = Object.freeze({ planHash, stepId: step.id, receipt: approvalReceipt }); registeredGrants.add(grant); return grant; }, async execute({ step, grant, signal }) { signal.throwIfAborted(); if (!registeredGrants.has(grant)) throw Error('UNTRUSTED_GRANT'); registeredGrants.delete(grant); // Recheck after authorization; wx never overwrites an existing file or symlink. if (fs.realpathSync(root) !== root) throw Error('ROOT_CHANGED'); fs.writeFileSync(step.args.path, step.args.text, { flag: 'wx', mode: 0o600 }); return { kind: 'FILE_CREATED', path: step.args.path, sha256: hash(fs.readFileSync(step.args.path)) }; }, async verify({ step, result }) { const stat = fs.lstatSync(step.args.path); return stat.isFile() && !stat.isSymbolicLink() && result?.kind === 'FILE_CREATED' && result.path === step.args.path && result.sha256 === hash(Buffer.from(step.args.text)) && hash(fs.readFileSync(step.args.path)) === result.sha256; }, }; }