deadbyte-mcp 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANIFEST.SHA256 +54 -44
- package/PROVENANCE.json +6 -5
- package/README.txt +36 -20
- package/RELEASE.json +24 -8
- package/SBOM.cdx.json +3 -3
- package/controller/README.TXT +2 -2
- package/controller/deadbyte-controller.ps1 +50 -2
- package/controller/deadbyte-process-policy.json +63 -1
- package/docs/ARCHITECTURE.md +3 -4
- package/package.json +9 -7
- package/scripts/deferred-slot-operation-r46.mjs +126 -0
- package/scripts/final-closure-r46.mjs +98 -0
- package/scripts/final-closure-verify-r46.mjs +119 -0
- package/scripts/full-regression-r46.mjs +20 -0
- package/scripts/gate-windows-r46.ps1 +56 -0
- package/scripts/refresh-process-policy.mjs +64 -7
- package/scripts/release-parity-r46.mjs +187 -0
- package/scripts/windows-gate-evidence-r46.mjs +110 -0
- package/src/capability-ledger.mjs +1 -0
- package/src/compact-gateway-mcp-tools.mjs +7 -5
- package/src/deadbyte-cli.mjs +7 -3
- package/src/machine-fs-mcp-tools.mjs +3 -3
- package/src/machine-fs-output-contracts.mjs +1 -1
- package/src/machine-fs-read-worker.mjs +46 -0
- package/src/machine-fs.mjs +63 -10
- package/src/mcp-server.mjs +4 -0
- package/src/mcp-smoke-client.mjs +4 -1
- package/src/observation-runtime.mjs +25 -4
- package/src/process-mcp-tools.mjs +18 -5
- package/src/process-output-contracts.mjs +20 -3
- package/src/process-policy.mjs +3 -1
- package/src/process-runtime.mjs +99 -19
- package/src/process-smoke-client.mjs +67 -22
- package/src/release-generation.mjs +22 -3
- package/src/remote-device-r44.mjs +53 -15
- package/src/remote-mcp-smoke-client.mjs +1 -1
- package/src/stdio-console-guard.mjs +24 -0
- package/src/version.mjs +1 -1
- package/test/cli-entrypoint.test.mjs +3 -3
- package/test/compact-gateway-r36.test.mjs +28 -0
- package/test/core.test.mjs +1 -1
- package/test/full-max-catalog-r46.test.mjs +92 -0
- package/test/machine-fs-mcp-tools.test.mjs +4 -1
- package/test/machine-fs-runtime.test.mjs +18 -2
- package/test/observation-runtime.test.mjs +29 -2
- package/test/process-mcp-tools.test.mjs +23 -5
- package/test/process-policy-refresh.test.mjs +87 -10
- package/test/process-runtime.test.mjs +111 -1
- package/test/process-supervision.test.mjs +4 -2
- package/test/production-docs.test.mjs +3 -3
- package/test/r33-finalization.test.mjs +3 -3
- package/test/release-generation.test.mjs +25 -11
- package/test/release-version.test.mjs +99 -44
- package/test/remote-device-r44.test.mjs +75 -0
- package/test/remote-r24-catalog.test.mjs +2 -2
package/src/machine-fs.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash, randomBytes, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto';
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
3
4
|
import { lstat, mkdir as fsMkdir, open, readFile, readdir, realpath, rename, rm, rmdir } from 'node:fs/promises';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import { canonicalJson } from './canonical-json.mjs';
|
|
6
8
|
import { assertCodingArmed, recheckCodingAuthority } from './coding-plane.mjs';
|
|
7
9
|
import { isInside, validatePortableRelativePath } from './path-policy.mjs';
|
|
@@ -29,6 +31,55 @@ async function sha256File(file) {
|
|
|
29
31
|
return hash.digest('hex');
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
const machineFsReadWorkerPath = fileURLToPath(new URL('./machine-fs-read-worker.mjs', import.meta.url));
|
|
35
|
+
async function boundedReadWorker({ file, offset, length, hashFile, timeoutMs, workerPath = machineFsReadWorkerPath }) {
|
|
36
|
+
return await new Promise((resolve, reject) => {
|
|
37
|
+
const child = spawn(process.execPath, [workerPath, file, String(offset), String(length), hashFile ? '1' : '0'], {
|
|
38
|
+
windowsHide: true, shell: false, stdio: ['ignore','pipe','pipe']
|
|
39
|
+
});
|
|
40
|
+
const stdout = [];
|
|
41
|
+
let stdoutBytes = 0;
|
|
42
|
+
let stderr = '';
|
|
43
|
+
let settled = false;
|
|
44
|
+
let timedOut = false;
|
|
45
|
+
const finish = (fn, value) => {
|
|
46
|
+
if (settled) return;
|
|
47
|
+
settled = true;
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
fn(value);
|
|
50
|
+
};
|
|
51
|
+
child.stdout.on('data', chunk => {
|
|
52
|
+
stdoutBytes += chunk.length;
|
|
53
|
+
if (stdoutBytes > 8 * 1024 * 1024) {
|
|
54
|
+
child.kill();
|
|
55
|
+
finish(reject, new Error('machine fs read worker output exceeded bound'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
stdout.push(Buffer.from(chunk));
|
|
59
|
+
});
|
|
60
|
+
child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString('utf8')).slice(-16384); });
|
|
61
|
+
child.once('error', error => finish(reject, error));
|
|
62
|
+
child.once('close', code => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
if (timedOut) return finish(reject, new Error(`machine fs read timed out after ${timeoutMs}ms`));
|
|
65
|
+
if (code !== 0) return finish(reject, new Error(`machine fs read worker failed exit=${String(code)}: ${stderr}`));
|
|
66
|
+
let value;
|
|
67
|
+
try { value = JSON.parse(Buffer.concat(stdout).toString('utf8')); }
|
|
68
|
+
catch { return finish(reject, new Error('machine fs read worker returned invalid JSON')); }
|
|
69
|
+
if (value?.schema !== 'deadbyte.machine-fs-read-worker.v1' || !Number.isInteger(value.bytes_read) ||
|
|
70
|
+
typeof value.data_base64 !== 'string' || (value.file_sha256 !== null && !SHA256_RE.test(value.file_sha256 ?? ''))) {
|
|
71
|
+
return finish(reject, new Error('machine fs read worker response invalid'));
|
|
72
|
+
}
|
|
73
|
+
finish(resolve, value);
|
|
74
|
+
});
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
timedOut = true;
|
|
77
|
+
child.kill();
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
32
83
|
function getRoot(policy, rootId, permission) {
|
|
33
84
|
const root = policy.roots[rootId];
|
|
34
85
|
if (!root) throw new Error(`machine fs root '${rootId}' is not allowed`);
|
|
@@ -245,7 +296,7 @@ async function atomicWrite(target, bytes, expectedSha256, authorityCheck = null)
|
|
|
245
296
|
return { before, after };
|
|
246
297
|
}
|
|
247
298
|
|
|
248
|
-
export function createMachineFsRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
299
|
+
export function createMachineFsRuntime({ policy, signingKeyPath, verifyKeyPath, readWorkerPath = machineFsReadWorkerPath }) {
|
|
249
300
|
if (!policy) throw new Error('machine fs runtime requires coding policy');
|
|
250
301
|
if (!signingKeyPath || !verifyKeyPath) throw new Error('machine fs runtime requires trust key paths');
|
|
251
302
|
|
|
@@ -292,25 +343,27 @@ export function createMachineFsRuntime({ policy, signingKeyPath, verifyKeyPath }
|
|
|
292
343
|
const nextCursor = filtered.length > selected.length && selected.length ? encodeCursor(selected.at(-1).name) : null;
|
|
293
344
|
return { status: 'ok', root_id: rootId, path: found.relative, entries, next_cursor: nextCursor };
|
|
294
345
|
}
|
|
295
|
-
async function readRange({ rootId, relativePath, offset = 0, maxBytes = 65536, hashFile = false }) {
|
|
346
|
+
async function readRange({ rootId, relativePath, offset = 0, maxBytes = 65536, hashFile = false, timeoutMs = 30000 }) {
|
|
296
347
|
if (!Number.isInteger(offset) || offset < 0) throw new Error('machine fs read offset must be nonnegative integer');
|
|
297
348
|
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > policy.max_read_bytes) throw new Error(`machine fs max_bytes must be in [1, ${policy.max_read_bytes}]`);
|
|
349
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 180000) throw new Error('machine fs read timeout_ms must be in [100, 180000]');
|
|
298
350
|
const grant = await assertCodingArmed(policy, rootId);
|
|
299
351
|
const found = await resolveExisting(policy, grant, rootId, relativePath, 'read', false);
|
|
300
352
|
const info = await lstat(found.resolved);
|
|
301
353
|
if (!info.isFile()) throw new Error('machine fs read target must be a regular file');
|
|
302
354
|
if (offset > info.size) throw new Error('machine fs read offset exceeds file size');
|
|
303
|
-
const handle = await open(found.resolved, 'r');
|
|
304
355
|
const length = Math.min(maxBytes, info.size - offset);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
356
|
+
await recheckCodingAuthority(policy, grant, rootId);
|
|
357
|
+
const worker = await boundedReadWorker({ file: found.resolved, offset, length, hashFile, timeoutMs, workerPath: readWorkerPath });
|
|
358
|
+
const buffer = Buffer.from(worker.data_base64, 'base64');
|
|
359
|
+
if (buffer.length !== worker.bytes_read || buffer.length > length) throw new Error('machine fs read worker byte count mismatch');
|
|
360
|
+
await recheckCodingAuthority(policy, grant, rootId);
|
|
309
361
|
return {
|
|
310
362
|
status: 'ok', root_id: rootId, path: found.relative,
|
|
311
|
-
offset, next_offset: offset + length, file_size: info.size,
|
|
312
|
-
eof: offset + length >= info.size, data_base64: buffer.toString('base64'),
|
|
313
|
-
data_sha256: sha256Bytes(buffer), file_sha256:
|
|
363
|
+
offset, next_offset: offset + buffer.length, file_size: info.size,
|
|
364
|
+
eof: offset + buffer.length >= info.size, data_base64: buffer.toString('base64'),
|
|
365
|
+
data_sha256: sha256Bytes(buffer), file_sha256: worker.file_sha256,
|
|
366
|
+
timeout_ms: timeoutMs
|
|
314
367
|
};
|
|
315
368
|
}
|
|
316
369
|
|
package/src/mcp-server.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { defaultPrivateKeyPath, defaultPublicKeyPath, loadPrivateSigningKey, loa
|
|
|
14
14
|
import { DEADBYTE_VERSION } from './version.mjs';
|
|
15
15
|
import { currentIngressAdapterMode, TUNNEL_STDIO_PROBE_COMPAT_MODE } from './ingress-mode.mjs';
|
|
16
16
|
import { installTunnelStdioProbeCompat } from './tunnel-stdio-compat.mjs';
|
|
17
|
+
import { installStdioConsoleGuard } from './stdio-console-guard.mjs';
|
|
17
18
|
import { decodeUtf8Strict } from './text-policy.mjs';
|
|
18
19
|
import { assertHostArmed, hostExec, hostExecInline, hostExecNative, hostFileRead, hostFileWrite, loadHostPolicy } from './host-plane.mjs';
|
|
19
20
|
import { createMachineRuntime } from './machine-identity.mjs';
|
|
@@ -231,6 +232,9 @@ const skillRoot=path.join(runtimeStateRoot,'skills');
|
|
|
231
232
|
await mkdir(skillRoot,{recursive:true});
|
|
232
233
|
const skillRuntime=await createSkillPlane({root:skillRoot,verifyKeyPath});
|
|
233
234
|
|
|
235
|
+
installStdioConsoleGuard();
|
|
236
|
+
if (process.env.DEADBYTE_STDIO_CONSOLE_GUARD_PROBE === '1') console.log('DEADBYTE_STDIO_CONSOLE_GUARD_PROBE');
|
|
237
|
+
|
|
234
238
|
const ingressAdapterMode = currentIngressAdapterMode();
|
|
235
239
|
installTunnelStdioProbeCompat({
|
|
236
240
|
enabled: ingressAdapterMode === TUNNEL_STDIO_PROBE_COMPAT_MODE,
|
package/src/mcp-smoke-client.mjs
CHANGED
|
@@ -38,7 +38,8 @@ const transport = new StdioClientTransport({
|
|
|
38
38
|
...getDefaultEnvironment(),
|
|
39
39
|
DEADBYTE_WORKSPACE_ROOT: workspaceRoot,
|
|
40
40
|
DEADBYTE_SIGNING_KEY: keys.privateKeyPath,
|
|
41
|
-
DEADBYTE_VERIFY_KEY: keys.publicKeyPath
|
|
41
|
+
DEADBYTE_VERIFY_KEY: keys.publicKeyPath,
|
|
42
|
+
DEADBYTE_STDIO_CONSOLE_GUARD_PROBE: '1'
|
|
42
43
|
},
|
|
43
44
|
stderr: 'pipe'
|
|
44
45
|
});
|
|
@@ -64,6 +65,8 @@ try {
|
|
|
64
65
|
|
|
65
66
|
const listed = await client.listTools();
|
|
66
67
|
const names = listed.tools.map(tool => tool.name).sort();
|
|
68
|
+
const guardStderr = Buffer.concat(serverStderr).toString('utf8');
|
|
69
|
+
if (!guardStderr.includes('DEADBYTE MCP LOG DEADBYTE_STDIO_CONSOLE_GUARD_PROBE')) throw new Error('stdio console guard probe did not reach stderr');
|
|
67
70
|
const baseTools = ['contained_transform', 'evidence_verify', 'file_read', 'proof_execute', 'skill_catalog', 'workspace_open'];
|
|
68
71
|
const privilegedTools = ['host_exec', 'host_file_read', 'host_file_write'];
|
|
69
72
|
const allowedCatalogs = [
|
|
@@ -133,14 +133,32 @@ function requestSha(policy,operation,args){
|
|
|
133
133
|
}
|
|
134
134
|
export function createObservationRuntime({policy,signingKeyPath,verifyKeyPath,processRuntime=null,stateRoot=path.join(os.homedir(),'.deadbyte-mcp'),
|
|
135
135
|
processSnapshotProvider=defaultProcessSnapshotProvider,systemSnapshotProvider=defaultSystemSnapshotProvider,
|
|
136
|
-
ownedRuntimePidProvider=defaultOwnedRuntimePidProvider,hardwareSnapshotProvider=defaultHardwareSnapshotProvider
|
|
136
|
+
ownedRuntimePidProvider=defaultOwnedRuntimePidProvider,hardwareSnapshotProvider=defaultHardwareSnapshotProvider,
|
|
137
|
+
searchSessionTtlMs=5*60*1000,maxSearchSessions=128,clockMs=()=>Date.now()}={}){
|
|
137
138
|
if(!policy||!signingKeyPath||!verifyKeyPath) throw new Error('observation runtime requires policy and trust keys');
|
|
139
|
+
if(!Number.isInteger(searchSessionTtlMs)||searchSessionTtlMs<10||searchSessionTtlMs>24*60*60*1000) throw new Error('searchSessionTtlMs out of range');
|
|
140
|
+
if(!Number.isInteger(maxSearchSessions)||maxSearchSessions<1||maxSearchSessions>4096) throw new Error('maxSearchSessions out of range');
|
|
141
|
+
if(typeof clockMs!=='function') throw new Error('clockMs must be a function');
|
|
138
142
|
const searches=new Map(),snapshots=new Map();
|
|
139
143
|
let signingPromise=null;
|
|
140
144
|
const runtime={policy,signing:()=>signingPromise??=(loadPrivateSigningKey(signingKeyPath))};
|
|
145
|
+
const terminalSearchState=state=>['completed','cancelled','failed'].includes(state);
|
|
146
|
+
function pruneSearches(){
|
|
147
|
+
const now=Number(clockMs());
|
|
148
|
+
if(!Number.isFinite(now)) throw new Error('search clock invalid');
|
|
149
|
+
for(const [id,session] of searches){
|
|
150
|
+
if(terminalSearchState(session.state)&&Number.isFinite(session.completed_at_ms)&&now-session.completed_at_ms>=searchSessionTtlMs) searches.delete(id);
|
|
151
|
+
}
|
|
152
|
+
if(searches.size>maxSearchSessions){
|
|
153
|
+
const terminal=[...searches.values()].filter(s=>terminalSearchState(s.state))
|
|
154
|
+
.sort((a,b)=>(a.completed_at_ms??Infinity)-(b.completed_at_ms??Infinity)||a.search_id.localeCompare(b.search_id));
|
|
155
|
+
for(const session of terminal){ if(searches.size<=maxSearchSessions) break; searches.delete(session.search_id); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
141
158
|
function searchFor(searchId){
|
|
142
159
|
if(!HEX64.test(searchId??'')) throw new Error('invalid search id');
|
|
143
|
-
|
|
160
|
+
pruneSearches();
|
|
161
|
+
const session=searches.get(searchId); if(!session) throw new Error(`unknown or expired search '${searchId}'`); return session;
|
|
144
162
|
}
|
|
145
163
|
async function finalizeSearch(session,state,error=null){
|
|
146
164
|
session.error=error?String(error):null;
|
|
@@ -149,7 +167,8 @@ export function createObservationRuntime({policy,signingKeyPath,verifyKeyPath,pr
|
|
|
149
167
|
bytes_scanned:session.bytes_scanned,truncated:session.truncated,error:session.error
|
|
150
168
|
});
|
|
151
169
|
session.receipt_id=receipt.receipt_id; session.receipt_sha256=receipt.receipt_sha256;
|
|
152
|
-
session.state=state;
|
|
170
|
+
session.state=state; session.completed_at_ms=Number(clockMs());
|
|
171
|
+
pruneSearches();
|
|
153
172
|
}
|
|
154
173
|
async function executeSearch(session){
|
|
155
174
|
if(session.cancel_requested){ await finalizeSearch(session,'cancelled'); return; }
|
|
@@ -200,11 +219,13 @@ export function createObservationRuntime({policy,signingKeyPath,verifyKeyPath,pr
|
|
|
200
219
|
if(!Number.isInteger(maxBytes)||maxBytes<1||maxBytes>64*1024*1024) throw new Error('max_bytes out of range');
|
|
201
220
|
if(!Number.isInteger(maxFileBytes)||maxFileBytes<1||maxFileBytes>4*1024*1024) throw new Error('max_file_bytes out of range');
|
|
202
221
|
rootFor(policy,rootId); if(relativePath!=='.') validatePortableRelativePath(relativePath);
|
|
222
|
+
pruneSearches();
|
|
223
|
+
if(searches.size>=maxSearchSessions) throw new Error('observation search session limit reached');
|
|
203
224
|
const args={root_id:rootId,path:relativePath,pattern,case_sensitive:caseSensitive,max_results:maxResults,max_files:maxFiles,max_bytes:maxBytes,max_file_bytes:maxFileBytes};
|
|
204
225
|
const session={search_id:randomBytes(32).toString('hex'),root_id:rootId,path:relativePath,pattern,case_sensitive:caseSensitive,
|
|
205
226
|
max_results:maxResults,max_files:maxFiles,max_bytes:maxBytes,max_file_bytes:maxFileBytes,state:'pending',cancel_requested:false,
|
|
206
227
|
results:[],files_scanned:0,bytes_scanned:0,truncated:false,result_head_sha256:ZERO,error:null,receipt_id:null,receipt_sha256:null,
|
|
207
|
-
request_sha256:requestSha(policy,'machine_search_start',args)};
|
|
228
|
+
created_at_ms:Number(clockMs()),completed_at_ms:null,request_sha256:requestSha(policy,'machine_search_start',args)};
|
|
208
229
|
searches.set(session.search_id,session); setImmediate(()=>{ void executeSearch(session); });
|
|
209
230
|
return {status:'accepted',search_id:session.search_id,state:session.state,request_sha256:session.request_sha256};
|
|
210
231
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
2
|
import {
|
|
3
3
|
processActionOutputSchema,
|
|
4
4
|
processListOutputSchema,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
processStartOutputSchema,
|
|
7
7
|
processStatusOutputSchema,
|
|
8
8
|
processTerminateOutputSchema,
|
|
9
|
+
processWriteOutputSchema,
|
|
9
10
|
processStructuredResult
|
|
10
11
|
} from './process-output-contracts.mjs';
|
|
11
12
|
|
|
@@ -30,14 +31,26 @@ export function registerProcessTools(server, { runtime }) {
|
|
|
30
31
|
server.registerTool('process_session_read', {
|
|
31
32
|
description: 'Read a bounded byte range from one DEADBYTE-owned process session using ordered stream offsets.',
|
|
32
33
|
inputSchema: z.strictObject({
|
|
33
|
-
session_id: hex64, offset: z.number().int().
|
|
34
|
-
max_bytes: z.number().int().min(1).max(262144).default(65536)
|
|
34
|
+
session_id: hex64, offset: z.number().int().min(-(16 * 1024 * 1024)).default(0),
|
|
35
|
+
max_bytes: z.number().int().min(1).max(262144).default(65536),
|
|
36
|
+
wait_ms: z.number().int().min(0).max(30000).default(0),
|
|
37
|
+
wait_for: z.string().min(1).max(256).nullable().default(null)
|
|
35
38
|
}), outputSchema: processReadOutputSchema
|
|
36
|
-
}, async ({ session_id, offset, max_bytes }) => {
|
|
37
|
-
try { return ok(processReadOutputSchema, { status: 'ok', ...(await runtime.read({ sessionId: session_id, offset, maxBytes: max_bytes })) }); }
|
|
39
|
+
}, async ({ session_id, offset, max_bytes, wait_ms, wait_for }) => {
|
|
40
|
+
try { return ok(processReadOutputSchema, { status: 'ok', ...(await runtime.read({ sessionId: session_id, offset, maxBytes: max_bytes, waitMs: wait_ms, waitFor: wait_for })) }); }
|
|
38
41
|
catch (error) { return fail(processReadOutputSchema, 'denied', error); }
|
|
39
42
|
});
|
|
40
43
|
|
|
44
|
+
server.registerTool('process_session_write', {
|
|
45
|
+
description: 'Write one bounded UTF-8 input payload to a running DEADBYTE-owned pipe session only when its local profile explicitly enables raw input. Input text is never stored in the signed journal; only byte count and SHA-256 identity are recorded.',
|
|
46
|
+
inputSchema: z.strictObject({
|
|
47
|
+
session_id: hex64, input: z.string().min(1).max(1024 * 1024), append_newline: z.boolean().default(true)
|
|
48
|
+
}), outputSchema: processWriteOutputSchema
|
|
49
|
+
}, async ({ session_id, input, append_newline }) => {
|
|
50
|
+
try { return ok(processWriteOutputSchema, { status: 'ok', ...(await runtime.write({ sessionId: session_id, input, appendNewline: append_newline })) }); }
|
|
51
|
+
catch (error) { return fail(processWriteOutputSchema, 'denied', error); }
|
|
52
|
+
});
|
|
53
|
+
|
|
41
54
|
server.registerTool('process_session_status', {
|
|
42
55
|
description: 'Read the verified state of one DEADBYTE-owned process session by session identity.',
|
|
43
56
|
inputSchema: z.strictObject({ session_id: hex64 }), outputSchema: processStatusOutputSchema
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
2
|
import { structuredToolResult } from './output-contracts.mjs';
|
|
3
3
|
|
|
4
4
|
const hex64 = z.string().regex(/^[0-9a-f]{64}$/);
|
|
@@ -51,6 +51,23 @@ export const processActionOutputSchema = z.strictObject({
|
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
export const processWriteOutputSchema = z.strictObject({
|
|
55
|
+
status: z.enum(['ok','denied','failed']),
|
|
56
|
+
error: z.string().min(1).max(4096).optional(),
|
|
57
|
+
logical_bytes: z.number().int().positive().optional(),
|
|
58
|
+
logical_sha256: hex64.optional(),
|
|
59
|
+
append_newline: z.boolean().optional(),
|
|
60
|
+
session: processSessionRecordSchema.optional()
|
|
61
|
+
}).superRefine((value, ctx) => {
|
|
62
|
+
if (value.status !== 'ok') {
|
|
63
|
+
if (!value.error) ctx.addIssue({ code: 'custom', path: ['error'], message: 'error required' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const key of ['logical_bytes','logical_sha256','append_newline','session']) {
|
|
67
|
+
if (value[key] === undefined) ctx.addIssue({ code: 'custom', path: [key], message: `${key} required` });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
54
71
|
export const processListOutputSchema = z.strictObject({
|
|
55
72
|
status: z.enum(['ok','failed']),
|
|
56
73
|
error: z.string().min(1).max(4096).optional(),
|
|
@@ -65,11 +82,11 @@ export const processReadOutputSchema = z.strictObject({
|
|
|
65
82
|
session_id: hex64.optional(), offset: z.number().int().nonnegative().optional(),
|
|
66
83
|
next_offset: z.number().int().nonnegative().optional(), earliest_offset: z.number().int().nonnegative().optional(),
|
|
67
84
|
stream_head: z.number().int().nonnegative().optional(), stream_sha256: hex64.optional(),
|
|
68
|
-
truncated: z.boolean().optional(), data_base64: z.string().max(400000).optional(),
|
|
85
|
+
truncated: z.boolean().optional(), wait_satisfied: z.boolean().optional(), data_base64: z.string().max(400000).optional(),
|
|
69
86
|
state: processState.optional(), exit_code: z.number().int().nullable().optional()
|
|
70
87
|
}).superRefine((value, ctx) => {
|
|
71
88
|
if (value.status !== 'ok') { if (!value.error) ctx.addIssue({ code: 'custom', path: ['error'], message: 'error required' }); return; }
|
|
72
|
-
for (const key of ['session_id','offset','next_offset','earliest_offset','stream_head','stream_sha256','truncated','data_base64','state','exit_code']) {
|
|
89
|
+
for (const key of ['session_id','offset','next_offset','earliest_offset','stream_head','stream_sha256','truncated','wait_satisfied','data_base64','state','exit_code']) {
|
|
73
90
|
if (value[key] === undefined) ctx.addIssue({ code: 'custom', path: [key], message: `${key} required` });
|
|
74
91
|
}
|
|
75
92
|
});
|
package/src/process-policy.mjs
CHANGED
|
@@ -63,6 +63,8 @@ function normalizePolicyObject(raw, policyPath) {
|
|
|
63
63
|
if (envAllow.some(name => !/^[A-Za-z_][A-Za-z0-9_()]*$/.test(name))) throw new Error(`process profile '${id}' env_allow invalid`);
|
|
64
64
|
const transport = value.transport ?? 'conpty';
|
|
65
65
|
if (transport !== 'conpty' && transport !== 'pipe') throw new Error(`process profile '${id}' transport invalid`);
|
|
66
|
+
if (value.allow_raw_input !== undefined && typeof value.allow_raw_input !== 'boolean') throw new Error(`process profile '${id}' allow_raw_input invalid`);
|
|
67
|
+
const allowRawInput = value.allow_raw_input === true;
|
|
66
68
|
const inputActions = {};
|
|
67
69
|
if (value.input_actions !== undefined) {
|
|
68
70
|
if (!value.input_actions || typeof value.input_actions !== 'object' || Array.isArray(value.input_actions)) throw new Error(`process profile '${id}' input_actions invalid`);
|
|
@@ -80,7 +82,7 @@ function normalizePolicyObject(raw, policyPath) {
|
|
|
80
82
|
max_sessions: finiteInteger(value.max_sessions, `profiles.${id}.max_sessions`, 1, 32, 4),
|
|
81
83
|
max_input_bytes: finiteInteger(value.max_input_bytes, `profiles.${id}.max_input_bytes`, 1, 1024 * 1024, 64 * 1024),
|
|
82
84
|
max_retained_output_bytes: finiteInteger(value.max_retained_output_bytes, `profiles.${id}.max_retained_output_bytes`, 4096, 16 * 1024 * 1024, 1024 * 1024),
|
|
83
|
-
transport, env_allow: [...new Set(envAllow)], input_actions: inputActions
|
|
85
|
+
transport, allow_raw_input: allowRawInput, env_allow: [...new Set(envAllow)], input_actions: inputActions
|
|
84
86
|
};
|
|
85
87
|
}
|
|
86
88
|
if (Object.keys(profiles).length === 0) throw new Error('process policy requires at least one profile');
|
package/src/process-runtime.mjs
CHANGED
|
@@ -60,6 +60,36 @@ function publicSession(session) {
|
|
|
60
60
|
journal_head_sha256: session.journal_head_sha256
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
+
function retainedSlice(session, begin = 0, length = session.retained_output_bytes - begin) {
|
|
64
|
+
if (!Number.isInteger(begin) || begin < 0 || !Number.isInteger(length) || length < 0) throw new Error('invalid retained output slice');
|
|
65
|
+
if (begin >= session.retained_output_bytes || length === 0) return Buffer.alloc(0);
|
|
66
|
+
const end = Math.min(session.retained_output_bytes, begin + length);
|
|
67
|
+
const parts = [];
|
|
68
|
+
let cursor = 0;
|
|
69
|
+
for (const chunk of session.output_chunks) {
|
|
70
|
+
const next = cursor + chunk.length;
|
|
71
|
+
if (next <= begin) { cursor = next; continue; }
|
|
72
|
+
if (cursor >= end) break;
|
|
73
|
+
const from = Math.max(0, begin - cursor);
|
|
74
|
+
const to = Math.min(chunk.length, end - cursor);
|
|
75
|
+
if (to > from) parts.push(chunk.subarray(from, to));
|
|
76
|
+
cursor = next;
|
|
77
|
+
}
|
|
78
|
+
if (parts.length === 0) return Buffer.alloc(0);
|
|
79
|
+
if (parts.length === 1) return Buffer.from(parts[0]);
|
|
80
|
+
return Buffer.concat(parts, end - begin);
|
|
81
|
+
}
|
|
82
|
+
function retainedAll(session) {
|
|
83
|
+
return retainedSlice(session, 0, session.retained_output_bytes);
|
|
84
|
+
}
|
|
85
|
+
function retainedTail(session, maxBytes) {
|
|
86
|
+
const length = Math.min(maxBytes, session.retained_output_bytes);
|
|
87
|
+
return retainedSlice(session, session.retained_output_bytes - length, length);
|
|
88
|
+
}
|
|
89
|
+
function retainedIndexOf(session, needle, begin = 0) {
|
|
90
|
+
if (!Buffer.isBuffer(needle) || needle.length < 1) return -1;
|
|
91
|
+
return retainedSlice(session, begin, session.retained_output_bytes - begin).indexOf(needle);
|
|
92
|
+
}
|
|
63
93
|
function appendOutput(session, chunk) {
|
|
64
94
|
if (!chunk?.length) return;
|
|
65
95
|
const bytes = Buffer.from(chunk);
|
|
@@ -67,13 +97,22 @@ function appendOutput(session, chunk) {
|
|
|
67
97
|
Buffer.from(session.stream_sha256, 'hex'), bytes
|
|
68
98
|
]));
|
|
69
99
|
session.stream_head += bytes.length;
|
|
70
|
-
session.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
session.
|
|
74
|
-
session.
|
|
100
|
+
session.output_chunks.push(bytes);
|
|
101
|
+
session.retained_output_bytes += bytes.length;
|
|
102
|
+
while (session.retained_output_bytes > session.max_retained_output_bytes && session.output_chunks.length > 0) {
|
|
103
|
+
const excess = session.retained_output_bytes - session.max_retained_output_bytes;
|
|
104
|
+
const first = session.output_chunks[0];
|
|
105
|
+
if (first.length <= excess) {
|
|
106
|
+
session.output_chunks.shift();
|
|
107
|
+
session.retained_output_bytes -= first.length;
|
|
108
|
+
session.earliest_offset += first.length;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
session.output_chunks[0] = first.subarray(excess);
|
|
112
|
+
session.retained_output_bytes -= excess;
|
|
113
|
+
session.earliest_offset += excess;
|
|
75
114
|
}
|
|
76
|
-
const tail = session
|
|
115
|
+
const tail = retainedTail(session, 4096).toString('latin1');
|
|
77
116
|
const enabledAt = tail.lastIndexOf('\x1b[?9001h');
|
|
78
117
|
const disabledAt = tail.lastIndexOf('\x1b[?9001l');
|
|
79
118
|
if (enabledAt >= 0 || disabledAt >= 0) session.win32_input_mode = enabledAt > disabledAt;
|
|
@@ -166,7 +205,7 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
166
205
|
child_pid:started.detail.child_pid, creation_filetime:String(started.detail.creation_filetime),
|
|
167
206
|
state:terminal.type, exit_code:Number.isInteger(terminal.detail.exit_code) ? terminal.detail.exit_code : null,
|
|
168
207
|
earliest_offset:terminal.detail.earliest_offset, stream_head:terminal.detail.stream_head,
|
|
169
|
-
stream_sha256:terminal.detail.stream_sha256, output, control_buffer:Buffer.alloc(0),
|
|
208
|
+
stream_sha256:terminal.detail.stream_sha256, output_chunks:[output], retained_output_bytes:output.length, control_buffer:Buffer.alloc(0),
|
|
170
209
|
journal_head_sha256:checked.head_sha256, journal_path:path.join(policy.state_dir,name), event_count:checked.event_count,
|
|
171
210
|
max_retained_output_bytes:profile.max_retained_output_bytes, stderr:'', terminate_requested:terminal.type==='terminated',
|
|
172
211
|
protocol_error:null, transport:profile.transport, win32_input_mode:false, host:null, close_promise:Promise.resolve(),
|
|
@@ -262,7 +301,7 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
262
301
|
const session = {
|
|
263
302
|
session_id: sessionId, job_id: randomBytes(32).toString('hex'), profile_id: profileId, root_id: rootId,
|
|
264
303
|
cwd_relative: cwdInfo.relative, child_pid: null, creation_filetime: null, state: 'starting', exit_code: null,
|
|
265
|
-
earliest_offset: 0, stream_head: 0, stream_sha256: ZERO_SHA256,
|
|
304
|
+
earliest_offset: 0, stream_head: 0, stream_sha256: ZERO_SHA256, output_chunks: [], retained_output_bytes: 0, control_buffer: Buffer.alloc(0),
|
|
266
305
|
journal_head_sha256: ZERO_SHA256, journal_path: path.join(policy.state_dir, `${sessionId}.jsonl`), event_count: 0,
|
|
267
306
|
max_retained_output_bytes: profile.max_retained_output_bytes, stderr: '', terminate_requested: false, protocol_error: null,
|
|
268
307
|
transport: profile.transport,
|
|
@@ -286,15 +325,16 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
286
325
|
const terminalState = session.terminate_requested ? 'terminated' : 'exited';
|
|
287
326
|
try {
|
|
288
327
|
const outputFile = `${session.session_id}.stream.bin`;
|
|
289
|
-
const
|
|
290
|
-
|
|
328
|
+
const retainedOutput = retainedAll(session);
|
|
329
|
+
const retainedOutputSha256 = sha256Bytes(retainedOutput);
|
|
330
|
+
await writeFile(path.join(policy.state_dir, outputFile), retainedOutput, { flag:'wx' });
|
|
291
331
|
await appendEvent(runtime, session, terminalState, {
|
|
292
332
|
recovery_schema:TERMINAL_RECOVERY_SCHEMA,
|
|
293
333
|
exit_code:session.exit_code,
|
|
294
334
|
earliest_offset:session.earliest_offset,
|
|
295
335
|
stream_head:session.stream_head,
|
|
296
336
|
stream_sha256:session.stream_sha256,
|
|
297
|
-
retained_output_bytes:session.
|
|
337
|
+
retained_output_bytes:session.retained_output_bytes,
|
|
298
338
|
retained_output_sha256:retainedOutputSha256,
|
|
299
339
|
output_file:outputFile
|
|
300
340
|
});
|
|
@@ -311,17 +351,57 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
311
351
|
return publicSession(session);
|
|
312
352
|
}
|
|
313
353
|
|
|
314
|
-
async function read({ sessionId, offset = 0, maxBytes = 65536 }) {
|
|
354
|
+
async function read({ sessionId, offset = 0, maxBytes = 65536, waitMs = 0, waitFor = null }) {
|
|
315
355
|
const session = await sessionFor(sessionId);
|
|
316
|
-
if (!Number.isInteger(offset) || offset <
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
if (!Number.isInteger(offset) || offset < -(16 * 1024 * 1024) || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 262144 ||
|
|
357
|
+
!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 30000) throw new Error('invalid process read bounds');
|
|
358
|
+
if (waitFor !== null && (typeof waitFor !== 'string' || waitFor.length < 1 || waitFor.includes('\0') || Buffer.byteLength(waitFor, 'utf8') > 256)) {
|
|
359
|
+
throw new Error('invalid process wait_for literal');
|
|
360
|
+
}
|
|
361
|
+
const requested = offset < 0 ? Math.max(0, session.stream_head + offset) : offset;
|
|
362
|
+
if (requested > session.stream_head) throw new Error('process read offset is beyond stream head');
|
|
363
|
+
const baseline = session.stream_head;
|
|
364
|
+
const literal = waitFor === null ? null : Buffer.from(waitFor, 'utf8');
|
|
365
|
+
const satisfied = () => {
|
|
366
|
+
if (literal === null) return session.stream_head > baseline || session.state !== 'running';
|
|
367
|
+
const effectiveSearch = Math.max(requested, session.earliest_offset);
|
|
368
|
+
const begin = effectiveSearch - session.earliest_offset;
|
|
369
|
+
return retainedIndexOf(session, literal, begin) >= 0;
|
|
370
|
+
};
|
|
371
|
+
if (waitMs > 0 && session.state === 'running' && !satisfied()) {
|
|
372
|
+
const deadline = Date.now() + waitMs;
|
|
373
|
+
while (session.state === 'running' && !satisfied() && Date.now() < deadline) {
|
|
374
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const effectiveRequested = offset < 0 ? Math.max(0, session.stream_head + offset) : requested;
|
|
378
|
+
const truncated = effectiveRequested < session.earliest_offset;
|
|
379
|
+
const effective = Math.max(effectiveRequested, session.earliest_offset);
|
|
320
380
|
const begin = effective - session.earliest_offset;
|
|
321
|
-
const data = session
|
|
381
|
+
const data = retainedSlice(session, begin, maxBytes);
|
|
322
382
|
return { session_id: session.session_id, offset: effective, next_offset: effective + data.length,
|
|
323
383
|
earliest_offset: session.earliest_offset, stream_head: session.stream_head, stream_sha256: session.stream_sha256,
|
|
324
|
-
truncated,
|
|
384
|
+
truncated, wait_satisfied: waitFor === null ? (session.stream_head > baseline || waitMs === 0 || session.state !== 'running') : satisfied(),
|
|
385
|
+
data_base64: data.toString('base64'), state: session.state, exit_code: session.exit_code };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function write({ sessionId, input, appendNewline = true }) {
|
|
389
|
+
const session = await sessionFor(sessionId);
|
|
390
|
+
if (session.state !== 'running') throw new Error('process write requires a running session');
|
|
391
|
+
const grant = await assertProcessArmed(policy, session.profile_id, session.root_id);
|
|
392
|
+
const profile = policy.profiles[session.profile_id];
|
|
393
|
+
if (!profile?.allow_raw_input) throw new Error(`process profile '${session.profile_id}' does not allow raw input`);
|
|
394
|
+
if (session.transport !== 'pipe' || profile.transport !== 'pipe') throw new Error('process raw input requires pipe transport');
|
|
395
|
+
if (typeof input !== 'string' || input.includes('\0')) throw new Error('process raw input must be a NUL-free string');
|
|
396
|
+
if (typeof appendNewline !== 'boolean') throw new Error('process appendNewline must be boolean');
|
|
397
|
+
const logicalBytes = Buffer.from(appendNewline ? `${input}\r\n` : input, 'utf8');
|
|
398
|
+
if (logicalBytes.length < 1 || logicalBytes.length > profile.max_input_bytes) throw new Error('process raw input exceeds profile input bound');
|
|
399
|
+
if (!session.host?.stdin || session.host.stdin.destroyed || !session.host.stdin.writable) throw new Error('process session input is not writable');
|
|
400
|
+
await recheckProcessAuthority(policy, grant, session.profile_id, session.root_id);
|
|
401
|
+
await new Promise((resolve, reject) => session.host.stdin.write(logicalBytes, error => error ? reject(error) : resolve()));
|
|
402
|
+
const logicalSha256 = sha256Bytes(logicalBytes);
|
|
403
|
+
await appendEvent(runtime, session, 'input', { logical_bytes: logicalBytes.length, logical_sha256: logicalSha256, append_newline: appendNewline });
|
|
404
|
+
return { logical_bytes: logicalBytes.length, logical_sha256: logicalSha256, append_newline: appendNewline, session: publicSession(session) };
|
|
325
405
|
}
|
|
326
406
|
|
|
327
407
|
async function status({ sessionId }) { return publicSession(await sessionFor(sessionId)); }
|
|
@@ -383,5 +463,5 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
383
463
|
return { ok: true, event_count: sequence, head_sha256: previous };
|
|
384
464
|
}
|
|
385
465
|
|
|
386
|
-
return { policy, start, read, status, list, action, terminate, verifyEventJournal };
|
|
466
|
+
return { policy, start, read, write, status, list, action, terminate, verifyEventJournal };
|
|
387
467
|
}
|
|
@@ -29,7 +29,7 @@ const policy = await loadProcessPolicy(smokePolicyPath);
|
|
|
29
29
|
const keys = await generateTrustKeypair({ privateKeyPath: path.join(smokeTrust, 'private.pem'), publicKeyPath: path.join(smokeTrust, 'public.pem') });
|
|
30
30
|
await mkdir(path.dirname(policy.grant_file), { recursive: true });
|
|
31
31
|
const grant = { schema: PROCESS_GRANT_SCHEMA, policy_sha256: policy.policy_sha256,
|
|
32
|
-
instance_nonce: randomBytes(32).toString('hex'), profile_ids: ['ping_hold'], root_ids: ['core'],
|
|
32
|
+
instance_nonce: randomBytes(32).toString('hex'), profile_ids: ['ping_hold','powershell_repl'], root_ids: ['core'],
|
|
33
33
|
granted_at_utc: new Date().toISOString() };
|
|
34
34
|
await writeFile(policy.grant_file, `${JSON.stringify(grant, null, 2)}\n`, { flag: 'wx' });
|
|
35
35
|
|
|
@@ -37,8 +37,10 @@ const client = new Client({ name: 'deadbyte-process-smoke', version: '0.8.0' },
|
|
|
37
37
|
{ versionNegotiation: { mode: { pin: '2026-07-28' } } });
|
|
38
38
|
const transport = new StdioClientTransport({
|
|
39
39
|
command: process.execPath, args: [serverPath], cwd: repoRoot,
|
|
40
|
-
env: { ...getDefaultEnvironment(),
|
|
41
|
-
|
|
40
|
+
env: { ...getDefaultEnvironment(), DEADBYTE_PRIVILEGED_POLICY: '', DEADBYTE_MACHINE_RUNTIME:'0', DEADBYTE_AGENT_RUNTIME:'0',
|
|
41
|
+
DEADBYTE_PROCESS_RUNTIME: '1', DEADBYTE_PROCESS_POLICY: smokePolicyPath, DEADBYTE_CODING_RUNTIME:'0', DEADBYTE_MACHINE_FS_RUNTIME:'0',
|
|
42
|
+
DEADBYTE_OBSERVATION_RUNTIME:'0', DEADBYTE_DESKTOP_RUNTIME:'0', DEADBYTE_AUTONOMOUS_RUNTIME:'0', DEADBYTE_AUDIT_RUNTIME:'0',
|
|
43
|
+
DEADBYTE_CAPABILITY_PROFILE:'full', DEADBYTE_SIGNING_KEY: keys.privateKeyPath, DEADBYTE_VERIFY_KEY: keys.publicKeyPath },
|
|
42
44
|
stderr: 'pipe'
|
|
43
45
|
});
|
|
44
46
|
const stderrChunks = [];
|
|
@@ -51,12 +53,13 @@ const structured = (result, label) => {
|
|
|
51
53
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
52
54
|
let sessionId = null;
|
|
53
55
|
let journalPath = null;
|
|
56
|
+
let replJournalPath = null;
|
|
54
57
|
try {
|
|
55
58
|
await client.connect(transport);
|
|
56
59
|
const listed = await client.listTools();
|
|
57
60
|
const names = listed.tools.map(tool => tool.name).sort();
|
|
58
61
|
const expected = ['contained_transform','evidence_verify','file_read','proof_execute','skill_catalog','workspace_open',
|
|
59
|
-
'process_session_action','process_session_list','process_session_read','process_session_start','process_session_status','process_session_terminate'].sort();
|
|
62
|
+
'process_session_action','process_session_list','process_session_read','process_session_start','process_session_status','process_session_terminate','process_session_write'].sort();
|
|
60
63
|
if (JSON.stringify(names) !== JSON.stringify(expected)) throw new Error(`process smoke catalog mismatch: ${JSON.stringify(names)}`);
|
|
61
64
|
const started = structured(await client.callTool({ name: 'process_session_start', arguments: {
|
|
62
65
|
profile_id: 'ping_hold', root_id: 'core', cwd: '.', args: []
|
|
@@ -93,31 +96,72 @@ try {
|
|
|
93
96
|
const terminated = structured(await client.callTool({ name: 'process_session_terminate', arguments: { session_id: sessionId } }), 'process_session_terminate');
|
|
94
97
|
if (terminated.status !== 'ok' || terminated.session.state !== 'terminated') throw new Error(`process terminate failed: ${JSON.stringify(terminated)}`);
|
|
95
98
|
const finalHead = terminated.session.journal_head_sha256;
|
|
99
|
+
|
|
100
|
+
const replStarted = structured(await client.callTool({ name: 'process_session_start', arguments: {
|
|
101
|
+
profile_id: 'powershell_repl', root_id: 'core', cwd: '.', args: []
|
|
102
|
+
} }), 'process_session_start powershell_repl');
|
|
103
|
+
const replSessionId = replStarted.session?.session_id;
|
|
104
|
+
if (replStarted.status !== 'ok' || !/^[0-9a-f]{64}$/.test(replSessionId ?? '')) throw new Error('powershell repl start malformed');
|
|
105
|
+
replJournalPath = path.join(policy.state_dir, `${replSessionId}.jsonl`);
|
|
106
|
+
const firstWrite = structured(await client.callTool({ name: 'process_session_write', arguments: {
|
|
107
|
+
session_id: replSessionId, input: "$x=41;[Console]::WriteLine('DB_PUBLIC_REPL_SET')", append_newline: true
|
|
108
|
+
} }), 'process_session_write set');
|
|
109
|
+
if (firstWrite.status !== 'ok' || firstWrite.session?.state !== 'running') throw new Error('powershell repl first write failed');
|
|
110
|
+
const beforeSecond = firstWrite.session.stream_head;
|
|
111
|
+
const secondWrite = structured(await client.callTool({ name: 'process_session_write', arguments: {
|
|
112
|
+
session_id: replSessionId, input: "[Console]::WriteLine('DB_PUBLIC_REPL_VALUE=' + ($x+1))", append_newline: true
|
|
113
|
+
} }), 'process_session_write value');
|
|
114
|
+
if (secondWrite.status !== 'ok') throw new Error('powershell repl second write failed');
|
|
115
|
+
const replRead = structured(await client.callTool({ name: 'process_session_read', arguments: {
|
|
116
|
+
session_id: replSessionId, offset: beforeSecond, max_bytes: 65536, wait_ms: 5000, wait_for: 'DB_PUBLIC_REPL_VALUE=42'
|
|
117
|
+
} }), 'process_session_read repl');
|
|
118
|
+
const replText = Buffer.from(replRead.data_base64 ?? '', 'base64').toString('utf8');
|
|
119
|
+
if (replRead.status !== 'ok' || replRead.wait_satisfied !== true || !replText.includes('DB_PUBLIC_REPL_VALUE=42')) {
|
|
120
|
+
throw new Error(`powershell repl state persistence proof failed: ${JSON.stringify(replRead)}`);
|
|
121
|
+
}
|
|
122
|
+
const replTail = structured(await client.callTool({ name: 'process_session_read', arguments: {
|
|
123
|
+
session_id: replSessionId, offset: -4096, max_bytes: 4096, wait_ms: 0, wait_for: null
|
|
124
|
+
} }), 'process_session_read tail');
|
|
125
|
+
if (!Buffer.from(replTail.data_base64 ?? '', 'base64').toString('utf8').includes('DB_PUBLIC_REPL_VALUE=42')) {
|
|
126
|
+
throw new Error('powershell repl tail read proof failed');
|
|
127
|
+
}
|
|
128
|
+
const replTerminated = structured(await client.callTool({ name: 'process_session_terminate', arguments: { session_id: replSessionId } }), 'process_session_terminate repl');
|
|
129
|
+
if (replTerminated.status !== 'ok' || replTerminated.session.state !== 'terminated') throw new Error('powershell repl terminate failed');
|
|
130
|
+
const replJournalText = await readFile(replJournalPath, 'utf8');
|
|
131
|
+
if (/DB_PUBLIC_REPL_SET|DB_PUBLIC_REPL_VALUE|\$x=41/.test(replJournalText)) throw new Error('powershell repl journal leaked raw input payload');
|
|
132
|
+
const replFinalHead = replTerminated.session.journal_head_sha256;
|
|
96
133
|
await client.close();
|
|
97
134
|
|
|
98
135
|
const verifyKey = await loadPublicVerifyKey(keys.publicKeyPath);
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
136
|
+
const verifyJournal = async (targetSessionId, targetJournalPath, expectedHead, minimumEvents) => {
|
|
137
|
+
const records = (await readFile(targetJournalPath, 'utf8')).split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
|
|
138
|
+
let previous = ZERO_SHA256;
|
|
139
|
+
let sequence = 0;
|
|
140
|
+
for (const record of records) {
|
|
141
|
+
const { event_sha256: eventSha, key_id: keyId, signature, ...unsigned } = record;
|
|
142
|
+
if (unsigned.session_id !== targetSessionId || unsigned.sequence !== sequence + 1 || unsigned.previous_event_sha256 !== previous || unsigned.policy_sha256 !== policy.policy_sha256) {
|
|
143
|
+
throw new Error('process journal chain mismatch');
|
|
144
|
+
}
|
|
145
|
+
const canonical = Buffer.from(canonicalJson(unsigned), 'utf8');
|
|
146
|
+
const observed = sha256(canonical);
|
|
147
|
+
if (eventSha !== observed || keyId !== verifyKey.keyId || !cryptoVerify(null, Buffer.concat([EVENT_DOMAIN, canonical]), verifyKey.publicKey, Buffer.from(signature, 'base64'))) {
|
|
148
|
+
throw new Error('process journal signature mismatch');
|
|
149
|
+
}
|
|
150
|
+
previous = observed;
|
|
151
|
+
sequence += 1;
|
|
106
152
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
previous = observed;
|
|
113
|
-
sequence += 1;
|
|
114
|
-
}
|
|
115
|
-
if (sequence < 3 || previous !== finalHead) throw new Error('process journal head mismatch');
|
|
153
|
+
if (sequence < minimumEvents || previous !== expectedHead) throw new Error('process journal head mismatch');
|
|
154
|
+
return { sequence, head: previous };
|
|
155
|
+
};
|
|
156
|
+
const pingVerified = await verifyJournal(sessionId, journalPath, finalHead, 3);
|
|
157
|
+
const replVerified = await verifyJournal(replSessionId, replJournalPath, replFinalHead, 4);
|
|
116
158
|
if ((await readFile(journalPath, 'utf8')).includes('DEADBYTE_ACTION_PING')) throw new Error('process journal leaked action payload');
|
|
117
|
-
console.log(JSON.stringify({ status: 'passed', protocol_revision: '2026-07-28', tool_count:
|
|
159
|
+
console.log(JSON.stringify({ status: 'passed', protocol_revision: '2026-07-28', tool_count: 13,
|
|
118
160
|
profile_id: 'ping_hold', session_id: sessionId, observed_output_bytes: read.stream_head,
|
|
119
161
|
action_id: action.action_id, logical_bytes: action.logical_bytes, logical_sha256: action.logical_sha256,
|
|
120
|
-
final_state: terminated.session.state, event_count: sequence, journal_head_sha256:
|
|
162
|
+
final_state: terminated.session.state, event_count: pingVerified.sequence, journal_head_sha256: pingVerified.head,
|
|
163
|
+
persistent_repl: { profile_id:'powershell_repl', session_id:replSessionId, state_persisted:true, wait_for:true, tail_read:true,
|
|
164
|
+
raw_input_not_journaled:true, event_count:replVerified.sequence, journal_head_sha256:replVerified.head },
|
|
121
165
|
policy_sha256: policy.policy_sha256, key_id: verifyKey.keyId, signature_valid: true }, null, 2));
|
|
122
166
|
} catch (error) {
|
|
123
167
|
const stderr = Buffer.concat(stderrChunks).toString('utf8').trim();
|
|
@@ -128,5 +172,6 @@ try {
|
|
|
128
172
|
await client.close().catch(() => {});
|
|
129
173
|
await rm(policy.grant_file, { force: true }).catch(() => {});
|
|
130
174
|
if (journalPath) await rm(journalPath, { force: true }).catch(() => {});
|
|
175
|
+
if (replJournalPath) await rm(replJournalPath, { force: true }).catch(() => {});
|
|
131
176
|
await rm(smokeTrust, { recursive: true, force: true }).catch(() => {});
|
|
132
177
|
}
|