deadbyte-mcp 0.15.74 → 0.15.75

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.
Files changed (103) hide show
  1. package/MANIFEST.SHA256 +102 -42
  2. package/PROVENANCE.json +19 -21
  3. package/README.txt +23 -18
  4. package/RELEASE.json +51 -18
  5. package/SBOM.cdx.json +2 -2
  6. package/TEST-MATRIX.json +22 -2
  7. package/bin/deadbyte-desktop-uia-r199-fixed.exe +0 -0
  8. package/bin/deadbyte-desktop-uia-r199-fixed.obj +0 -0
  9. package/bin/deadbyte-desktop-uia-r199.exe +0 -0
  10. package/bin/deadbyte-desktop-uia-r199.obj +0 -0
  11. package/bin/deadbyte-fs-read-r197-fixed.exe +0 -0
  12. package/bin/deadbyte-fs-read-r198.exe +0 -0
  13. package/bin/deadbyte-fs-read-r198.obj +0 -0
  14. package/bin/deadbyte-root-relative-probe-r197.exe +0 -0
  15. package/bridge/package.json +1 -1
  16. package/controller/deadbyte-process-policy.json +4 -4
  17. package/docs/R184-package-lock.json +2 -8
  18. package/native/deadbyte-desktop-uia-r199.cpp +345 -0
  19. package/native/deadbyte-fs-read-r197.cpp +174 -0
  20. package/native/deadbyte-fs-read-r198.cpp +181 -0
  21. package/native/deadbyte-root-relative-probe-r197.cpp +97 -0
  22. package/package.json +4 -2
  23. package/scripts/build-native-desktop-uia-r199-fixed.ps1 +28 -0
  24. package/scripts/build-native-desktop-uia-r199.ps1 +28 -0
  25. package/scripts/build-native-fs-read-r197-fixed.ps1 +28 -0
  26. package/scripts/build-native-fs-read-r197.ps1 +28 -0
  27. package/scripts/build-native-fs-read-r198.ps1 +28 -0
  28. package/scripts/build-native-root-probe-r197.ps1 +28 -0
  29. package/scripts/gate-windows-r195.ps1 +114 -0
  30. package/scripts/gate-windows-r206.ps1 +116 -0
  31. package/scripts/init-desktop-policy-r199.ps1 +85 -0
  32. package/scripts/npm-release-r48.mjs +17 -0
  33. package/scripts/npm-release-readiness-r195.mjs +72 -0
  34. package/scripts/npm-release-readiness-r206.mjs +76 -0
  35. package/scripts/r193-owner-request-live.mjs +5 -6
  36. package/scripts/r193-owner-uac-broker.mjs +3 -3
  37. package/scripts/r205-candidate-selection.mjs +22 -0
  38. package/src/candidate-selection-evidence-r206.mjs +71 -0
  39. package/src/candidate-selection-r205.mjs +96 -0
  40. package/src/desktop-postcondition-r196.mjs +30 -0
  41. package/src/desktop-runtime-r40.mjs +44 -7
  42. package/src/desktop-uia-identity-r199.mjs +51 -0
  43. package/src/desktop-uia-r40.mjs +9 -1
  44. package/src/machine-fs-handle-read-r196.mjs +43 -0
  45. package/src/machine-fs-native-read-r197.mjs +73 -0
  46. package/src/machine-fs-native-read-r198.mjs +76 -0
  47. package/src/machine-fs-read-worker.mjs +5 -25
  48. package/src/machine-fs.mjs +31 -16
  49. package/src/r195-canonical-owner-observation.mjs +15 -0
  50. package/src/remote-destination-journal-r202.mjs +120 -0
  51. package/src/remote-device-r44.mjs +89 -14
  52. package/src/remote-payload-boundary-r201.mjs +71 -0
  53. package/src/remote-terminal-evidence-r200.mjs +60 -0
  54. package/src/version.mjs +1 -1
  55. package/test/desktop-runtime-r40.test.mjs +111 -0
  56. package/test/desktop-uia-r40.test.mjs +15 -0
  57. package/test/fixtures/r197-junction-swap-worker.mjs +21 -0
  58. package/test/fixtures/r203-journal-writer.mjs +6 -0
  59. package/test/process-policy-refresh.test.mjs +4 -4
  60. package/test/r130-release-security-contract.test.mjs +8 -8
  61. package/test/r132-release-identity.test.mjs +7 -7
  62. package/test/r153-prepublish-identity.test.mjs +1 -1
  63. package/test/r160-release-contract.test.mjs +3 -3
  64. package/test/r165-live-cli-skew.test.mjs +1 -1
  65. package/test/r167-release-contract.test.mjs +11 -11
  66. package/test/r168-release-contract.test.mjs +4 -4
  67. package/test/r172-release-contract.test.mjs +6 -6
  68. package/test/r173-release-contract.test.mjs +16 -16
  69. package/test/r179-release-contract.test.mjs +12 -12
  70. package/test/r190-release-contract.test.mjs +10 -10
  71. package/test/r191-release-contract.test.mjs +12 -12
  72. package/test/r192-release-identity.test.mjs +9 -9
  73. package/test/r193-release-identity.test.mjs +24 -22
  74. package/test/r193-release-readiness.test.mjs +2 -2
  75. package/test/r193-windows-gate.test.mjs +8 -8
  76. package/test/r194-release-identity.test.mjs +19 -15
  77. package/test/r194-release-readiness.test.mjs +2 -2
  78. package/test/r194-windows-gate.test.mjs +7 -6
  79. package/test/r195-canonical-owner-observation.test.mjs +32 -0
  80. package/test/r195-release-readiness.test.mjs +22 -0
  81. package/test/r196-desktop-postcondition.test.mjs +53 -0
  82. package/test/r196-handle-bound-read.test.mjs +99 -0
  83. package/test/r196-remote-outcome.test.mjs +94 -0
  84. package/test/r197-namespace-race.test.mjs +41 -0
  85. package/test/r197-native-dispatch-race.test.mjs +49 -0
  86. package/test/r197-native-file-read.test.mjs +60 -0
  87. package/test/r197-native-root-open.test.mjs +61 -0
  88. package/test/r198-root-identity.test.mjs +88 -0
  89. package/test/r199-native-uia-worker.test.mjs +28 -0
  90. package/test/r199-uia-identity.test.mjs +43 -0
  91. package/test/r200-remote-reconcile.test.mjs +106 -0
  92. package/test/r201-payload-boundary.test.mjs +78 -0
  93. package/test/r201-payload-fuzz.test.mjs +37 -0
  94. package/test/r201-tool-boundary-fuzz.test.mjs +120 -0
  95. package/test/r202-destination-journal.test.mjs +92 -0
  96. package/test/r203-atomic-journal.test.mjs +86 -0
  97. package/test/r205-candidate-selection-cli.test.mjs +31 -0
  98. package/test/r205-candidate-selection.test.mjs +83 -0
  99. package/test/r206-candidate-evidence.test.mjs +64 -0
  100. package/test/r206-release-contract.test.mjs +42 -0
  101. package/test/r206-release-readiness.test.mjs +23 -0
  102. package/test/r59-tool-surface-compat.test.mjs +1 -1
  103. package/test/r64-machine-fs-large-read.test.mjs +26 -12
@@ -10,6 +10,8 @@ import { isInside, validatePortableRelativePath } from './path-policy.mjs';
10
10
  import { decodeUtf8Strict } from './text-policy.mjs';
11
11
  import { loadPrivateSigningKey, loadPublicVerifyKey } from './trust.mjs';
12
12
  import { atomicMutation } from './atomic-mutation.mjs';
13
+ import { readRangeFromHandle } from './machine-fs-handle-read-r196.mjs';
14
+ import { boundedNativeReadR198 } from './machine-fs-native-read-r198.mjs';
13
15
 
14
16
  export const MACHINE_FS_RECEIPT_SCHEMA = 'deadbyte.machine-fs-receipt.v1';
15
17
  const RECEIPT_DOMAIN = Buffer.from('DEADBYTE-MACHINE-FS-RECEIPT-V1\0', 'utf8');
@@ -48,14 +50,7 @@ async function boundedReadInProcess({ file, offset, length, hashFile, timeoutMs
48
50
  timer.unref?.();
49
51
  });
50
52
  try {
51
- const operation = (async () => {
52
- const buffer = Buffer.alloc(length);
53
- const result = length ? await handle.read(buffer, 0, length, offset) : { bytesRead: 0 };
54
- let fileSha256 = null;
55
- if (hashFile) fileSha256 = await sha256File(file, abortController.signal);
56
- return { schema: 'deadbyte.machine-fs-read-worker.v1', bytes_read: result.bytesRead,
57
- data_base64: buffer.subarray(0, result.bytesRead).toString('base64'), file_sha256: fileSha256 };
58
- })();
53
+ const operation = readRangeFromHandle(handle, { offset, length, hashFile, signal: abortController.signal });
59
54
  operation.catch(() => {});
60
55
  return await Promise.race([operation, abort]);
61
56
  } finally {
@@ -414,22 +409,42 @@ export function createMachineFsRuntime({ policy, signingKeyPath, verifyKeyPath,
414
409
  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}]`);
415
410
  if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 180000) throw new Error('machine fs read timeout_ms must be in [100, 180000]');
416
411
  const grant = await assertCodingReadAllowed(policy, rootId);
412
+ // Capture the root object before path validation. The native worker must
413
+ // compare its opened root HANDLE identity before resolving any child.
414
+ const authorizedRoot = getRoot(policy, rootId, 'read');
415
+ const rootBefore = await lstat(authorizedRoot.real_path, { bigint: true });
416
+ if (rootBefore.isSymbolicLink() || !rootBefore.isDirectory() || rootBefore.ino === 0n)
417
+ throw new Error('R198 root directory identity unavailable');
417
418
  const found = await resolveExisting(policy, grant, rootId, relativePath, 'read', false);
418
- const info = await lstat(found.resolved);
419
+ // Node's numeric ino can round NTFS file IDs above 2^53.
420
+ // Keep the pre-open identity as BigInt and compare exact strings from the worker's fstat.
421
+ const info = await lstat(found.resolved, { bigint: true });
419
422
  if (!info.isFile()) throw new Error('machine fs read target must be a regular file');
420
- if (!Number.isSafeInteger(info.size)) throw new Error('machine fs read file size exceeds safe integer range');
421
- const effectiveOffset = offset < 0 ? Math.max(0, info.size + offset) : offset;
422
- if (effectiveOffset > info.size) throw new Error('machine fs read offset exceeds file size');
423
- const length = Math.min(maxBytes, info.size - effectiveOffset);
423
+ if (info.size > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('machine fs read file size exceeds safe integer range');
424
+ if (info.ino === 0n) throw new Error('R196 file object identity unavailable');
425
+ const fileSize = Number(info.size);
426
+ const effectiveOffset = offset < 0 ? Math.max(0, fileSize + offset) : offset;
427
+ if (effectiveOffset > fileSize) throw new Error('machine fs read offset exceeds file size');
428
+ const length = Math.min(maxBytes, fileSize - effectiveOffset);
424
429
  await recheckCodingReadAuthority(policy, grant, rootId);
425
- const worker = await boundedReadWorker({ file: found.resolved, offset: effectiveOffset, length, hashFile, timeoutMs, workerPath: readWorkerPath });
430
+ const worker = process.platform === 'win32' && readWorkerPath === machineFsReadWorkerPath
431
+ ? await boundedNativeReadR198({
432
+ rootPath: found.root.real_path, relativePath: found.relative,
433
+ offset: effectiveOffset, length, hashFile, timeoutMs,
434
+ expectedRootDev: String(rootBefore.dev), expectedRootIno: String(rootBefore.ino)
435
+ })
436
+ : await boundedReadWorker({ file: found.resolved, offset: effectiveOffset, length, hashFile, timeoutMs, workerPath: readWorkerPath });
426
437
  const buffer = Buffer.from(worker.data_base64, 'base64');
427
438
  if (buffer.length !== worker.bytes_read || buffer.length > length) throw new Error('machine fs read worker byte count mismatch');
439
+ if (!Number.isSafeInteger(worker.file_size) || worker.file_size !== fileSize)
440
+ throw new Error('R196 opened file size drifted after precheck');
441
+ if (worker.file_dev !== String(info.dev) || worker.file_ino !== String(info.ino))
442
+ throw new Error('R196 opened file object identity differs from precheck');
428
443
  await recheckCodingReadAuthority(policy, grant, rootId);
429
444
  return {
430
445
  status: 'ok', root_id: rootId, path: found.relative,
431
- offset: effectiveOffset, requested_offset: offset, next_offset: effectiveOffset + buffer.length, file_size: info.size,
432
- eof: effectiveOffset + buffer.length >= info.size, data_base64: buffer.toString('base64'),
446
+ offset: effectiveOffset, requested_offset: offset, next_offset: effectiveOffset + buffer.length, file_size: fileSize,
447
+ eof: effectiveOffset + buffer.length >= fileSize, data_base64: buffer.toString('base64'),
433
448
  data_sha256: sha256Bytes(buffer), file_sha256: worker.file_sha256,
434
449
  timeout_ms: timeoutMs
435
450
  };
@@ -0,0 +1,15 @@
1
+ import path from 'node:path';
2
+
3
+ export async function observeCanonicalController({stateRoot,powershell,env,execFileAsync}={}){
4
+ if(typeof stateRoot!=='string'||!path.win32.isAbsolute(stateRoot))
5
+ throw new Error('R195 canonical owner observation requires an absolute state root');
6
+ if(typeof powershell!=='string'||powershell.length<1||typeof execFileAsync!=='function')
7
+ throw new Error('R195 canonical owner observation dependency invalid');
8
+ const launcher=path.win32.join(path.win32.resolve(stateRoot),'bin','DEADBYTE-MCP.ps1');
9
+ const {stdout}=await execFileAsync(powershell,[
10
+ '-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',
11
+ launcher,'status','-Json'
12
+ ],{env,windowsHide:true,shell:false,timeout:20000,maxBuffer:1024*1024,encoding:'utf8'});
13
+ try{return JSON.parse(stdout);}
14
+ catch{throw new Error('R195 canonical owner observation returned invalid JSON');}
15
+ }
@@ -0,0 +1,120 @@
1
+ // R202 experimental destination-side storage for already-signed terminal evidence.
2
+ // This does not execute effects, sign new claims, authorize a capability, or retry a request.
3
+ import {createHash,randomBytes} from 'node:crypto';
4
+ import {link,lstat,mkdir,open,realpath,unlink} from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import {verifySignedRemoteTerminalR200} from './remote-terminal-evidence-r200.mjs';
7
+ import {canonicalJson} from './canonical-json.mjs';
8
+
9
+ const HEX64=/^[0-9a-f]{64}$/;
10
+ const MAX_RECEIPT_BYTES=16*1024;
11
+ const digest=bytes=>createHash('sha256').update(bytes).digest('hex');
12
+ function insist(ok,message){if(!ok)throw new Error('R202 destination journal '+message);}
13
+
14
+ export async function openRemoteDestinationJournalR202({directory,device}={}){
15
+ insist(typeof directory==='string'&&path.isAbsolute(directory),'directory invalid');
16
+ insist(device&&HEX64.test(device.device_id??'')&&Number.isSafeInteger(device.epoch)&&
17
+ device.epoch>=1&&typeof device.public_key_spki_base64==='string','device identity invalid');
18
+ await mkdir(directory,{recursive:true});
19
+ const inputRootInfo=await lstat(directory);
20
+ insist(inputRootInfo.isDirectory()&&!inputRootInfo.isSymbolicLink(),'input root is a reparse point');
21
+ const dir=await realpath(directory);
22
+ const rootInfo=await lstat(dir);
23
+ insist(rootInfo.isDirectory()&&!rootInfo.isSymbolicLink(),'root is not a real directory');
24
+ const filename=requestId=>{
25
+ insist(HEX64.test(requestId??''),'request ID invalid');
26
+ return path.join(dir,requestId+'.json');
27
+ };
28
+ // The bytes and identity come from the same opened file object, not a new path lookup.
29
+ async function checkedRead(file){
30
+ let before;
31
+ try{before=await lstat(file,{bigint:true});}
32
+ catch(error){if(error?.code==='ENOENT')return null;throw error;}
33
+ insist(before.isFile()&&!before.isSymbolicLink()&&before.size>=1n&&
34
+ before.size<=BigInt(MAX_RECEIPT_BYTES),'record is not a bounded regular file');
35
+ const h=await open(file,'r');
36
+ let bytes;
37
+ try{
38
+ const initial=await h.stat({bigint:true});
39
+ insist(initial.isFile()&&initial.dev===before.dev&&initial.ino===before.ino,
40
+ 'record namespace swapped before handle open');
41
+ insist(initial.size>=1n&&initial.size<=BigInt(MAX_RECEIPT_BYTES),
42
+ 'record size changed before read');
43
+ const buffer=Buffer.alloc(MAX_RECEIPT_BYTES+1);
44
+ const {bytesRead}=await h.read(buffer,0,buffer.length,0);
45
+ bytes=buffer.subarray(0,bytesRead);
46
+ const after=await h.stat({bigint:true});
47
+ insist(after.size===initial.size&&after.mtimeNs===initial.mtimeNs&&
48
+ after.ctimeNs===initial.ctimeNs&&after.dev===initial.dev&&after.ino===initial.ino&&
49
+ BigInt(bytesRead)===initial.size,'record mutated during handle read');
50
+ }finally{await h.close();}
51
+ const pathAfter=await lstat(file,{bigint:true});
52
+ insist(pathAfter.isFile()&&!pathAfter.isSymbolicLink()&&
53
+ pathAfter.ino===before.ino&&pathAfter.dev===before.dev,
54
+ 'record namespace swapped after handle read');
55
+ let envelope;
56
+ try{envelope=JSON.parse(bytes.toString('utf8'));}
57
+ catch{throw new Error('R203 destination journal stored terminal is corrupt');}
58
+ return {envelope,bytes,sha256:digest(bytes)};
59
+ }
60
+ function verify(envelope,request){
61
+ insist(request&&request.device_id===device.device_id&&request.epoch===device.epoch&&
62
+ HEX64.test(request.request_id??'')&&HEX64.test(request.request_sha256??'')&&
63
+ typeof request.capability_id==='string','request identity invalid');
64
+ return verifySignedRemoteTerminalR200(envelope,{request,device});
65
+ }
66
+ return Object.freeze({
67
+ directory:dir,
68
+ device_id:device.device_id,
69
+ async commitSignedTerminal({envelope,request}={}){
70
+ const proof=verify(envelope,request);
71
+ const body=Buffer.from(canonicalJson(envelope)+'\n','utf8');
72
+ insist(body.length<=MAX_RECEIPT_BYTES,'signed terminal exceeds bound');
73
+ const file=filename(request.request_id);
74
+ // fsync the complete temp before an atomic, create-only hardlink publication.
75
+ // No direct write to final name: a crash before link cannot poison it.
76
+ const temp=path.join(dir,'.'+request.request_id+'.'+randomBytes(12).toString('hex')+'.tmp');
77
+ let tempCreated=false, published=false;
78
+ try{
79
+ const h=await open(temp,'wx',0o600);
80
+ tempCreated=true;
81
+ try{await h.writeFile(body);await h.sync();}
82
+ finally{await h.close();}
83
+ try{
84
+ await link(temp,file);
85
+ published=true;
86
+ }catch(error){
87
+ if(error?.code!=='EEXIST')throw error;
88
+ const old=await checkedRead(file);
89
+ insist(old!==null,'record missing after conflict');
90
+ verify(old.envelope,request);
91
+ insist(old.sha256===digest(body),'conflicting signed terminal for one request');
92
+ return Object.freeze({status:'existing',replayed:true,request_id:request.request_id,
93
+ terminal_evidence_sha256:proof.terminal_evidence_sha256});
94
+ }
95
+ }finally{
96
+ if(tempCreated)await unlink(temp).catch(error=>{
97
+ if(error?.code!=='ENOENT' && !published)throw error;
98
+ });
99
+ }
100
+ const written=await checkedRead(file);
101
+ insist(written!==null&&written.sha256===digest(body),'postcommit bytes mismatch');
102
+ verify(written.envelope,request);
103
+ return Object.freeze({status:'stored',replayed:false,request_id:request.request_id,
104
+ terminal_evidence_sha256:proof.terminal_evidence_sha256});
105
+ },
106
+ async lookupTerminal({device_id,epoch,request_id,request_sha256}={}){
107
+ insist(device_id===device.device_id&&epoch===device.epoch&&
108
+ HEX64.test(request_id??'')&&HEX64.test(request_sha256??''),
109
+ 'lookup request identity invalid');
110
+ const stored=await checkedRead(filename(request_id));
111
+ if(stored===null)return null;
112
+ const e=stored.envelope?.evidence;
113
+ insist(e&&e.request_sha256===request_sha256&&e.request_id===request_id,
114
+ 'lookup request hash mismatch');
115
+ const request={device_id,epoch,request_id,request_sha256,capability_id:e.capability_id};
116
+ verify(stored.envelope,request);
117
+ return structuredClone(stored.envelope);
118
+ }
119
+ });
120
+ }
@@ -1,6 +1,8 @@
1
1
  import { createHash, createPublicKey, randomBytes } from 'node:crypto';
2
2
  import { lstat, mkdir, open, readFile, realpath, rename } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import {verifySignedRemoteTerminalR200} from './remote-terminal-evidence-r200.mjs';
5
+ import {normalizeRemotePayloadR201} from './remote-payload-boundary-r201.mjs';
4
6
 
5
7
  const STATE_SCHEMA='deadbyte.remote-device-state.v1';
6
8
  const CREDENTIAL_SCHEMA='deadbyte.remote-credential.v1';
@@ -36,6 +38,9 @@ function validateState(value){
36
38
  insist(HEX64.test(id)&&r.request_id===id,'remote request identity invalid');
37
39
  insist(HEX64.test(r.request_sha256??''),'remote request hash invalid');
38
40
  insist(['accepted','executing','completed','failed'].includes(r.state),'remote request state invalid');
41
+ if(r.transport_state!==undefined)insist(['prepared','acknowledged','ack_missing','timed_out','transport_error','terminal_received','reconciled'].includes(r.transport_state),'remote transport state invalid');
42
+ if(r.effect_state!==undefined)insist(['unknown','confirmed','failed'].includes(r.effect_state),'remote effect state invalid');
43
+ if(r.reconciliation_state!==undefined)insist(['signed_terminal','signed_terminal_effect_unknown'].includes(r.reconciliation_state),'remote reconciliation state invalid');
39
44
  }
40
45
  return value;
41
46
  }
@@ -69,12 +74,13 @@ async function withTimeout(operation,timeoutMs,label){
69
74
  try{
70
75
  return await Promise.race([
71
76
  Promise.resolve().then(operation),
72
- new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error(`${label} timed out after ${timeoutMs}ms`)),timeoutMs);timer.unref?.();})
77
+ new Promise((_,reject)=>{timer=setTimeout(()=>{const error=new Error(`${label} timed out after ${timeoutMs}ms`);error.code='REMOTE_TIMEOUT';reject(error);},timeoutMs);timer.unref?.();})
73
78
  ]);
74
79
  }finally{if(timer)clearTimeout(timer);}
75
80
  }
76
81
  export function payloadHash({deviceId,epoch,capabilityId,payload}){
77
- return sha(Buffer.from(canonicalJson({device_id:deviceId,epoch,capability_id:capabilityId,payload}),'utf8'));
82
+ const safePayload=normalizeRemotePayloadR201(payload);
83
+ return sha(Buffer.from(canonicalJson({device_id:deviceId,epoch,capability_id:capabilityId,payload:safePayload}),'utf8'));
78
84
  }
79
85
 
80
86
  export async function createRemoteDevicePlaneR44({stateDir,relay,authorizeCapability,onCredentialSuperseded=null,relayTimeoutMs=15000,probeRetries=1,reachableTtlMs=75000}={}){
@@ -160,9 +166,9 @@ export async function createRemoteDevicePlaneR44({stateDir,relay,authorizeCapabi
160
166
  async function dispatch({deviceId,epoch,capabilityId,payload={},requestId}={}){
161
167
  insist(HEX64.test(requestId??''),'remote request id invalid');
162
168
  insist(typeof capabilityId==='string'&&CAP.test(capabilityId),'remote capability id invalid');
163
- insist(payload&&typeof payload==='object','remote payload must be JSON object');
164
- let payloadJson;try{payloadJson=canonicalJson(payload);}catch{throw new Error('remote payload is not JSON serializable');}
165
- const requestSha=payloadHash({deviceId,epoch,capabilityId,payload});
169
+ const safePayload=normalizeRemotePayloadR201(payload);
170
+ const payloadJson=canonicalJson(safePayload);
171
+ const requestSha=payloadHash({deviceId,epoch,capabilityId,payload:safePayload});
166
172
  const existing=state.requests[requestId];
167
173
  if(existing){
168
174
  insist(existing.request_sha256===requestSha,'remote request id conflict');
@@ -170,43 +176,112 @@ export async function createRemoteDevicePlaneR44({stateDir,relay,authorizeCapabi
170
176
  }
171
177
  const allowed=await authorizeCapability({deviceId,epoch,capabilityId});
172
178
  insist(allowed===true,'remote capability denied by local policy');
179
+ let claimedNew=false;
173
180
  await withLock(async()=>{
174
181
  const d=getDevice(deviceId,epoch);insist(d.reported_online&&fresh(d),'remote device not reachable or readiness probe is stale');
175
182
  const again=state.requests[requestId];
176
183
  if(again){insist(again.request_sha256===requestSha,'remote request id conflict');return;}
177
184
  state.requests[requestId]={request_id:requestId,request_sha256:requestSha,device_id:deviceId,epoch,capability_id:capabilityId,
178
- payload_sha256:sha(Buffer.from(payloadJson,'utf8')),state:'accepted',created_at_utc:now(),updated_at_utc:now()};
179
- await persist();
185
+ payload_sha256:sha(Buffer.from(payloadJson,'utf8')),state:'accepted',transport_state:'prepared',effect_state:'unknown',
186
+ created_at_utc:now(),updated_at_utc:now()};
187
+ await persist();claimedNew=true;
180
188
  });
181
- if(state.requests[requestId].state!=='accepted')return {...structuredClone(state.requests[requestId]),replay:true};
189
+ if(!claimedNew)return {...structuredClone(state.requests[requestId]),replay:true};
182
190
  let response=null;
183
191
  try{
184
192
  response=await withTimeout(
185
- ()=>relay.request({device_id:deviceId,epoch,capability_id:capabilityId,payload,request_id:requestId,request_sha256:requestSha}),
193
+ ()=>relay.request({device_id:deviceId,epoch,capability_id:capabilityId,payload:safePayload,request_id:requestId,request_sha256:requestSha}),
186
194
  relayTimeoutMs,'remote dispatch'
187
195
  );
188
196
  }catch(error){
189
197
  return withLock(async()=>{
190
- const rec=state.requests[requestId];rec.state='failed';rec.error_code='transport_error';rec.error=String(error?.message??error);rec.updated_at_utc=now();
198
+ const rec=state.requests[requestId];rec.state='failed';rec.error_code='transport_error';
199
+ rec.transport_state=error?.code==='REMOTE_TIMEOUT'?'timed_out':'transport_error';rec.effect_state='unknown';
200
+ rec.error=String(error?.message??error);rec.updated_at_utc=now();
191
201
  const d=state.devices[deviceId];if(d&&d.epoch===epoch){d.reachable=false;d.updated_at_utc=now();}
192
202
  await persist();return structuredClone(rec);
193
203
  });
194
204
  }
195
205
  if(!response||response.ack!=='accepted'||response.request_id!==requestId){
196
- return withLock(async()=>{const rec=state.requests[requestId];rec.state='failed';rec.error_code='transport_no_ack';rec.updated_at_utc=now();await persist();return structuredClone(rec);});
206
+ return withLock(async()=>{const rec=state.requests[requestId];rec.state='failed';rec.error_code='transport_no_ack';rec.transport_state='ack_missing';rec.effect_state='unknown';rec.updated_at_utc=now();await persist();return structuredClone(rec);});
197
207
  }
198
- await withLock(async()=>{const rec=state.requests[requestId];rec.state='executing';rec.acknowledged_at_utc=now();rec.updated_at_utc=now();await persist();});
208
+ await withLock(async()=>{const rec=state.requests[requestId];rec.state='executing';rec.transport_state='acknowledged';rec.effect_state='unknown';rec.acknowledged_at_utc=now();rec.updated_at_utc=now();await persist();});
199
209
  const terminal=response.terminal;
200
210
  if(!terminal||!['completed','failed'].includes(terminal.status)){
201
- return withLock(async()=>{const rec=state.requests[requestId];rec.state='failed';rec.error_code='transport_no_terminal';rec.updated_at_utc=now();await persist();return structuredClone(rec);});
211
+ return withLock(async()=>{const rec=state.requests[requestId];rec.state='failed';rec.error_code='transport_no_terminal';rec.transport_state='acknowledged';rec.effect_state='unknown';rec.updated_at_utc=now();await persist();return structuredClone(rec);});
202
212
  }
203
213
  return withLock(async()=>{
204
214
  const rec=state.requests[requestId];rec.state=terminal.status;
215
+ rec.transport_state='terminal_received';rec.effect_state=terminal.status==='completed'?'confirmed':'failed';
205
216
  if(terminal.status==='completed'){insist(HEX64.test(terminal.result_sha256??''),'remote terminal result hash invalid');rec.result_sha256=terminal.result_sha256;}
206
217
  else {rec.error_code=typeof terminal.error_code==='string'?terminal.error_code:'remote_failed';}
207
218
  rec.updated_at_utc=now();rec.completed_at_utc=now();await persist();return structuredClone(rec);
208
219
  });
209
220
  }
221
+ // Read-only lookup: never calls relay.request, never creates a new request ID,
222
+ // and never turns a transport timeout into a claim that the mutation failed.
223
+ async function reconcile({requestId}={}){
224
+ insist(HEX64.test(requestId??''),'R200 reconciliation request id invalid');
225
+ insist(typeof relay.lookupTerminal==='function','R200 signed terminal lookup unavailable');
226
+ const snapshot=await withLock(async()=>{
227
+ const rec=state.requests[requestId];
228
+ insist(rec,'R200 reconciliation request unknown');
229
+ const device=getDevice(rec.device_id,rec.epoch);
230
+ return {request:structuredClone(rec),device:{
231
+ device_id:device.device_id,epoch:device.epoch,
232
+ public_key_spki_base64:device.public_key_spki_base64
233
+ }};
234
+ });
235
+ if(snapshot.request.effect_state!=='unknown')
236
+ return {...snapshot.request,replay:true,reconciliation:'already_terminal'};
237
+ let answer=null;
238
+ try{
239
+ answer=await withTimeout(()=>relay.lookupTerminal({
240
+ device_id:snapshot.request.device_id,epoch:snapshot.request.epoch,
241
+ request_id:requestId,request_sha256:snapshot.request.request_sha256
242
+ }),relayTimeoutMs,'remote terminal reconciliation');
243
+ }catch(error){
244
+ return {...snapshot.request,reconciliation:'unresolved',
245
+ reconciliation_error_code:error?.code==='REMOTE_TIMEOUT'?'lookup_timeout':'lookup_transport_error'};
246
+ }
247
+ if(answer===null||answer===undefined)
248
+ return {...snapshot.request,reconciliation:'unresolved'};
249
+ const proof=verifySignedRemoteTerminalR200(answer,snapshot);
250
+ return withLock(async()=>{
251
+ const record=state.requests[requestId];
252
+ insist(record?.request_sha256===snapshot.request.request_sha256,
253
+ 'R200 reconciliation record changed before commit');
254
+ const current=getDevice(record.device_id,record.epoch);
255
+ insist(current.public_key_spki_base64===snapshot.device.public_key_spki_base64,
256
+ 'R200 reconciliation pairing changed before commit');
257
+ if(record.effect_state!=='unknown')
258
+ return {...structuredClone(record),replay:true,reconciliation:'already_terminal'};
259
+ record.transport_state='reconciled';
260
+ record.terminal_evidence_sha256=proof.terminal_evidence_sha256;
261
+ record.terminal_status=proof.terminal.status;
262
+ record.signed_by_device_id=proof.signed_by_device_id;
263
+ record.reconciled_at_utc=now();
264
+ record.updated_at_utc=now();
265
+ record.reconciliation_state=proof.effective_state==='unknown'
266
+ ?'signed_terminal_effect_unknown':'signed_terminal';
267
+ if(proof.effective_state==='confirmed'){
268
+ record.state='completed';record.effect_state='confirmed';
269
+ record.result_sha256=proof.terminal.result_sha256;
270
+ record.completed_at_utc=now();
271
+ }else if(proof.effective_state==='failed'){
272
+ record.state='failed';record.effect_state='failed';
273
+ record.error_code=proof.terminal.error_code;
274
+ record.completed_at_utc=now();
275
+ }else{
276
+ record.state='failed';record.effect_state='unknown';
277
+ record.error_code=proof.terminal.error_code;
278
+ }
279
+ await persist();
280
+ return {...structuredClone(record),reconciliation:
281
+ proof.effective_state==='unknown'?'effect_unknown':'signed_terminal'};
282
+ });
283
+ }
284
+
210
285
  async function revoke({deviceId,epoch}={}){
211
286
  return withLock(async()=>{
212
287
  const d=getDevice(deviceId,epoch);d.revoked=true;d.reported_online=false;d.reachable=false;d.connection_id=null;d.updated_at_utc=now();await persist();
@@ -239,5 +314,5 @@ export async function createRemoteDevicePlaneR44({stateDir,relay,authorizeCapabi
239
314
  return {status:'committed',device_id:deviceId,epoch,credential_sha256:credentialSha,previous_credential_sha256:previous?.credential_sha256??null};
240
315
  }
241
316
  async function list(){return {status:'ok',devices:Object.values(state.devices).map(pub).sort((a,b)=>a.device_id.localeCompare(b.device_id)),requests:structuredClone(state.requests)};}
242
- return Object.freeze({pair,status,reportOnline,probe,dispatch,revoke,rotateCredential,list,state_file:stateFile});
317
+ return Object.freeze({pair,status,reportOnline,probe,dispatch,reconcile,revoke,rotateCredential,list,state_file:stateFile});
243
318
  }
@@ -0,0 +1,71 @@
1
+ // R201: a remote capability may only receive a detached, bounded JSON value.
2
+ // Never use JSON.stringify on caller-owned objects: getters and toJSON can execute.
3
+ import {types as utilTypes} from 'node:util';
4
+
5
+ const MAX_BYTES=1024*1024;
6
+ const MAX_DEPTH=32;
7
+ const MAX_NODES=4096;
8
+ const MAX_KEYS=256;
9
+ const FORBIDDEN_KEYS=new Set(['__proto__','prototype','constructor','toJSON']);
10
+ function reject(message){throw new Error('remote payload '+message);}
11
+ function checkString(value,label){
12
+ if(Buffer.byteLength(value,'utf8')>MAX_BYTES)reject(label+' too large');
13
+ return value;
14
+ }
15
+ export function normalizeRemotePayloadR201(input){
16
+ let nodes=0;
17
+ const ancestors=new WeakSet();
18
+ function visit(value,depth){
19
+ if(depth>MAX_DEPTH)reject('depth exceeded');
20
+ if(++nodes>MAX_NODES)reject('node budget exceeded');
21
+ if(value===null||typeof value==='boolean')return value;
22
+ if(typeof value==='string')return checkString(value,'string');
23
+ if(typeof value==='number'){
24
+ if(!Number.isFinite(value)||Object.is(value,-0)||
25
+ (Number.isInteger(value)&&!Number.isSafeInteger(value)))reject('number must be finite, safe and unambiguous');
26
+ return value;
27
+ }
28
+ if(typeof value!=='object')reject('non-JSON value');
29
+ if(utilTypes.isProxy(value))reject('proxy object not allowed');
30
+ if(ancestors.has(value))reject('cycle detected');
31
+ const isArray=Array.isArray(value);
32
+ if(!isArray && ![null,Object.prototype].includes(Object.getPrototypeOf(value)))
33
+ reject('non-plain object not allowed');
34
+ ancestors.add(value);
35
+ try{
36
+ const descriptors=Object.getOwnPropertyDescriptors(value);
37
+ const names=Object.keys(descriptors);
38
+ if(Reflect.ownKeys(value).length!==names.length)reject('symbol property not allowed');
39
+ if(names.length>MAX_NODES)reject('property budget exceeded');
40
+ const result=isArray?[]:Object.create(null);
41
+ if(isArray){
42
+ const length=descriptors.length?.value;
43
+ if(!Number.isSafeInteger(length)||length<0||length>MAX_NODES)
44
+ reject('array length invalid');
45
+ if(names.length!==length+1)reject('sparse or decorated array not allowed');
46
+ for(let i=0;i<length;i++){
47
+ const desc=descriptors[String(i)];
48
+ if(!desc||!('value' in desc)||!desc.enumerable)reject('sparse or accessor array not allowed');
49
+ result.push(visit(desc.value,depth+1));
50
+ }
51
+ }else{
52
+ if(names.length>MAX_KEYS)reject('object key budget exceeded');
53
+ for(const key of names.sort()){
54
+ if(FORBIDDEN_KEYS.has(key))reject('unsafe key');
55
+ checkString(key,'key');
56
+ const desc=descriptors[key];
57
+ if(!desc?.enumerable||!('value' in desc))reject('accessor or hidden property not allowed');
58
+ result[key]=visit(desc.value,depth+1);
59
+ }
60
+ }
61
+ return Object.freeze(result);
62
+ }finally{
63
+ ancestors.delete(value);
64
+ }
65
+ }
66
+ const safe=visit(input,0);
67
+ if(!safe||Array.isArray(safe)||typeof safe!=='object')reject('top-level object required');
68
+ const encoded=JSON.stringify(safe);
69
+ if(Buffer.byteLength(encoded,'utf8')>MAX_BYTES)reject('encoded byte budget exceeded');
70
+ return safe;
71
+ }
@@ -0,0 +1,60 @@
1
+ import {createHash,createPublicKey,verify as edVerify} from 'node:crypto';
2
+ import {canonicalJson} from './canonical-json.mjs';
3
+
4
+ export const REMOTE_TERMINAL_EVIDENCE_SCHEMA='deadbyte.remote-terminal-evidence-r200.v1';
5
+ export const REMOTE_TERMINAL_DOMAIN=Buffer.from('DEADBYTE-REMOTE-TERMINAL-R200\0','utf8');
6
+ const HEX64=/^[0-9a-f]{64}$/;
7
+ const CODE=/^[A-Za-z0-9._-]{1,128}$/;
8
+ function insist(ok,message){if(!ok)throw new Error(message);}
9
+ function exactKeys(value,keys,label){
10
+ insist(value&&typeof value==='object'&&!Array.isArray(value),label+' invalid');
11
+ insist(Object.keys(value).sort().join('|')===keys.sort().join('|'),label+' contains unexpected or missing fields');
12
+ }
13
+ function canonicalBase64(value,label){
14
+ insist(typeof value==='string'&&value.length<=8192&&value.length>0,label+' invalid');
15
+ const decoded=Buffer.from(value,'base64');
16
+ insist(decoded.length>0&&decoded.toString('base64')===value,label+' noncanonical');
17
+ return decoded;
18
+ }
19
+ export function verifySignedRemoteTerminalR200(response,{request,device}={}){
20
+ exactKeys(response,['evidence','attestation'],'R200 remote terminal envelope');
21
+ const body=response.evidence,signature=response.attestation;
22
+ exactKeys(body,['schema','device_id','epoch','request_id','request_sha256','capability_id','terminal'],'R200 remote terminal body');
23
+ insist(body.schema===REMOTE_TERMINAL_EVIDENCE_SCHEMA,'R200 terminal schema mismatch');
24
+ insist(HEX64.test(body.device_id??'')&&body.device_id===request?.device_id&&body.device_id===device?.device_id,
25
+ 'R200 terminal device identity mismatch');
26
+ insist(Number.isSafeInteger(body.epoch)&&body.epoch===request?.epoch&&body.epoch===device?.epoch,
27
+ 'R200 terminal epoch mismatch');
28
+ insist(HEX64.test(body.request_id??'')&&body.request_id===request?.request_id,
29
+ 'R200 terminal request identity mismatch');
30
+ insist(HEX64.test(body.request_sha256??'')&&body.request_sha256===request?.request_sha256,
31
+ 'R200 terminal request hash mismatch');
32
+ insist(body.capability_id===request?.capability_id,'R200 terminal capability mismatch');
33
+ const terminal=body.terminal;
34
+ insist(terminal&&typeof terminal==='object'&&!Array.isArray(terminal),'R200 terminal result missing');
35
+ if(terminal.status==='completed'){
36
+ exactKeys(terminal,['status','result_sha256'],'R200 completed terminal');
37
+ insist(HEX64.test(terminal.result_sha256??''),'R200 terminal result digest invalid');
38
+ }else if(terminal.status==='failed'){
39
+ exactKeys(terminal,['status','error_code','effect_state'],'R200 failed terminal');
40
+ insist(CODE.test(terminal.error_code??''),'R200 terminal error code invalid');
41
+ insist(['unknown','not_started'].includes(terminal.effect_state),
42
+ 'R200 failure effect state must be unknown or not_started');
43
+ }else throw new Error('R200 terminal status invalid');
44
+ exactKeys(signature,['algorithm','key_id','signature_base64'],'R200 remote attestation');
45
+ insist(signature.algorithm==='ed25519'&&signature.key_id===device.device_id,'R200 signer identity mismatch');
46
+ const sig=canonicalBase64(signature.signature_base64,'R200 Ed25519 signature');
47
+ insist(sig.length===64,'R200 Ed25519 signature size invalid');
48
+ const der=canonicalBase64(device.public_key_spki_base64,'R200 paired device public key');
49
+ insist(createHash('sha256').update(der).digest('hex')===device.device_id,'R200 paired device key mismatch');
50
+ const key=createPublicKey({key:der,format:'der',type:'spki'});
51
+ insist(key.asymmetricKeyType==='ed25519','R200 paired device is not Ed25519');
52
+ const bytes=Buffer.concat([REMOTE_TERMINAL_DOMAIN,Buffer.from(canonicalJson(body),'utf8')]);
53
+ insist(edVerify(null,bytes,key,sig),'R200 remote terminal signature invalid');
54
+ return Object.freeze({
55
+ terminal:Object.freeze({...terminal}),
56
+ terminal_evidence_sha256:createHash('sha256').update(bytes).digest('hex'),
57
+ signed_by_device_id:device.device_id,
58
+ effective_state:terminal.status==='completed'?'confirmed':terminal.effect_state==='not_started'?'failed':'unknown'
59
+ });
60
+ }
package/src/version.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export const DEADBYTE_VERSION = '0.15.74';
1
+ export const DEADBYTE_VERSION = '0.15.75';
2
2
  export const MCP_PROTOCOL_VERSION = '2026-07-28';
3
3
  export const RECEIPT_SCHEMA_V1 = 'deadbyte.receipt.v1';
4
4
  export const RECEIPT_SCHEMA_V2 = 'deadbyte.receipt.v2';