50 lines
2.6 KiB
JavaScript
50 lines
2.6 KiB
JavaScript
|
|
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;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|