@letterblack/lbe-sdk 0.4.0
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/CHANGELOG.md +47 -0
- package/COMMERCIAL_DISTRIBUTION.md +29 -0
- package/LICENSE +12 -0
- package/README.md +190 -0
- package/dist/cli.js +122 -0
- package/dist/engine.js +78 -0
- package/dist/index.js +678 -0
- package/dist/mcp-server.js +62 -0
- package/package.json +60 -0
- package/runtime/lbe_engine.wasm +0 -0
- package/types.d.ts +72 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import crypto from'crypto';import fs from'fs';import os from'os';import path from'path';
|
|
2
|
+
import nacl from'tweetnacl';import{canonicalize}from'json-canonicalize';
|
|
3
|
+
import{runValidationPipeline,checkNonce,checkRateLimit,computeAuditHash,classifyRisk,shouldRollback}from'./engine.js';
|
|
4
|
+
|
|
5
|
+
// ── Ed25519 sign / verify ─────────────────────────────────────────────────────
|
|
6
|
+
function b64d(s){return Buffer.from(s,'base64')}
|
|
7
|
+
function b64e(b){return Buffer.from(b).toString('base64')}
|
|
8
|
+
function verifyEd25519({payloadObj,sigB64,pubKeyB64}){
|
|
9
|
+
try{
|
|
10
|
+
let msg=Buffer.from(canonicalize(payloadObj),'utf8');
|
|
11
|
+
let ok=nacl.sign.detached.verify(new Uint8Array(msg),new Uint8Array(b64d(sigB64)),new Uint8Array(b64d(pubKeyB64)));
|
|
12
|
+
return{valid:ok,message:ok?'Signature verified':'Signature verification failed'}
|
|
13
|
+
}catch(e){return{valid:false,message:`Signature verification error: ${e.message}`}}
|
|
14
|
+
}
|
|
15
|
+
function generateKeyPair(){let k=nacl.sign.keyPair();return{publicKey:b64e(k.publicKey),secretKey:b64e(k.secretKey)}}
|
|
16
|
+
function signPayload({payloadObj,secretKeyB64}){
|
|
17
|
+
try{
|
|
18
|
+
let msg=Buffer.from(canonicalize(payloadObj),'utf8');
|
|
19
|
+
let sig=nacl.sign.detached(new Uint8Array(msg),new Uint8Array(b64d(secretKeyB64)));
|
|
20
|
+
return{signature:b64e(sig),error:null}
|
|
21
|
+
}catch(e){return{signature:null,error:`Signing failed: ${e.message}`}}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Atomic file write ─────────────────────────────────────────────────────────
|
|
25
|
+
var LOCK_OPTS={timeoutMs:5000,pollMs:15,staleMs:30000};
|
|
26
|
+
function lockPath(p){return p+'.lock'}
|
|
27
|
+
function tryLock(p){
|
|
28
|
+
try{let fd=fs.openSync(p,'wx');fs.writeSync(fd,`pid:${process.pid}:${Date.now()}`);fs.closeSync(fd);return true}
|
|
29
|
+
catch(e){if(e.code==='EEXIST'||e.code==='EPERM'||e.code==='EBUSY'||e.code==='EACCES')return false;throw e}
|
|
30
|
+
}
|
|
31
|
+
function stalePurge(p,ms){try{if(Date.now()-fs.statSync(p).mtimeMs>ms)fs.unlinkSync(p)}catch{}}
|
|
32
|
+
function busySleep(ms){let end=Date.now()+ms;while(Date.now()<end)try{Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,Math.max(1,end-Date.now()))}catch{}}
|
|
33
|
+
function withLock(p,opts,fn){
|
|
34
|
+
let{timeoutMs,pollMs,staleMs}={...LOCK_OPTS,...(typeof opts==='function'?{}:opts)};
|
|
35
|
+
let cb=typeof opts==='function'?opts:fn;
|
|
36
|
+
let lp=lockPath(p);fs.mkdirSync(path.dirname(p),{recursive:true});
|
|
37
|
+
let deadline=Date.now()+timeoutMs,acquired=false;
|
|
38
|
+
while(!acquired){acquired=tryLock(lp);if(!acquired){if(Date.now()>=deadline){stalePurge(lp,staleMs);acquired=tryLock(lp);if(acquired)break;let e=new Error(`withLock: timeout ${lp}`);e.code='ELOCKTIMEOUT';throw e}stalePurge(lp,staleMs);busySleep(pollMs+Math.floor(Math.random()*pollMs))}}
|
|
39
|
+
try{return cb()}finally{try{fs.unlinkSync(lp)}catch{}}
|
|
40
|
+
}
|
|
41
|
+
function atomicWrite(p,data,opts={}){
|
|
42
|
+
fs.mkdirSync(path.dirname(p),{recursive:true});
|
|
43
|
+
let tmp=path.join(path.dirname(p),`.tmp-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
|
|
44
|
+
try{fs.writeFileSync(tmp,data,opts);fs.renameSync(tmp,p)}
|
|
45
|
+
catch(e){try{fs.existsSync(tmp)&&fs.unlinkSync(tmp)}catch{}throw e}
|
|
46
|
+
}
|
|
47
|
+
function readJson(p){
|
|
48
|
+
try{if(!fs.existsSync(p))return null;return JSON.parse(fs.readFileSync(p,'utf8'))}
|
|
49
|
+
catch{return null}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Flag extraction ───────────────────────────────────────────────────────────
|
|
53
|
+
// Only field presence + format checks. No governance logic.
|
|
54
|
+
|
|
55
|
+
function schemaFlags(cmd){
|
|
56
|
+
let has=k=>cmd!=null&&Object.prototype.hasOwnProperty.call(cmd,k);
|
|
57
|
+
let isStr=v=>typeof v==='string';
|
|
58
|
+
let p=cmd?.payload,sig=cmd?.signature;
|
|
59
|
+
return{
|
|
60
|
+
hasId:has('id'),idValid:isStr(cmd?.id)&&/^[A-Z_]+$/.test(cmd.id)&&cmd.id.length>=1&&cmd.id.length<=50,
|
|
61
|
+
hasCommandId:has('commandId'),commandIdValid:isStr(cmd?.commandId)&&/^[a-f0-9-]+$/.test(cmd.commandId)&&cmd.commandId.length===36,
|
|
62
|
+
hasRequesterId:has('requesterId'),requesterIdValid:isStr(cmd?.requesterId)&&cmd.requesterId.length>=3&&cmd.requesterId.length<=100,
|
|
63
|
+
hasSessionId:has('sessionId'),sessionIdValid:isStr(cmd?.sessionId)&&cmd.sessionId.length>=3,
|
|
64
|
+
hasTimestamp:has('timestamp'),timestampValid:typeof cmd?.timestamp==='number'&&cmd.timestamp>=1e9,
|
|
65
|
+
hasNonce:has('nonce'),nonceValid:isStr(cmd?.nonce)&&cmd.nonce.length>=32&&cmd.nonce.length<=128,
|
|
66
|
+
hasRequires:has('requires'),requiresValid:Array.isArray(cmd?.requires)&&cmd.requires.length>=1&&cmd.requires.every(isStr),
|
|
67
|
+
hasPayload:has('payload')&&typeof p==='object'&&p!==null&&!Array.isArray(p),
|
|
68
|
+
hasPayloadAdapter:p!=null&&Object.prototype.hasOwnProperty.call(p,'adapter'),payloadAdapterValid:isStr(p?.adapter),
|
|
69
|
+
hasSignature:has('signature')&&typeof sig==='object'&&sig!==null&&!Array.isArray(sig),
|
|
70
|
+
hasSignatureAlg:sig!=null&&Object.prototype.hasOwnProperty.call(sig,'alg'),signatureAlgValid:sig?.alg==='ed25519',
|
|
71
|
+
hasSignatureKeyId:sig!=null&&Object.prototype.hasOwnProperty.call(sig,'keyId'),
|
|
72
|
+
hasSignatureSig:sig!=null&&Object.prototype.hasOwnProperty.call(sig,'sig'),signatureSigValid:isStr(sig?.sig)&&sig.sig.length>=10,
|
|
73
|
+
hasRisk:has('risk'),riskValid:['LOW','MEDIUM','HIGH','CRITICAL'].includes(cmd?.risk),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function policyFlags(policy,cmd){
|
|
78
|
+
let hasP=!!(policy&&policy.default==='DENY'&&policy.requesters&&typeof policy.requesters==='object');
|
|
79
|
+
let rp=policy?.requesters?.[cmd.requesterId];
|
|
80
|
+
let cmdId=(cmd?.id||'').toLowerCase();
|
|
81
|
+
let commandAllowed=!!(rp?.allowCommands?.some(c=>c.toLowerCase()===cmdId));
|
|
82
|
+
let adapterAllowed=!!(rp?.allowAdapters?.includes(cmd.payload?.adapter));
|
|
83
|
+
let filesystemRequired=!!(cmd.payload?.cwd);
|
|
84
|
+
let filesystemRootsDefined=false,filesystemOk=false,pathDenied=false;
|
|
85
|
+
if(filesystemRequired){
|
|
86
|
+
let roots=rp?.filesystem?.roots??[];
|
|
87
|
+
filesystemRootsDefined=roots.length>0;
|
|
88
|
+
if(filesystemRootsDefined){
|
|
89
|
+
let cwd=path.resolve(cmd.payload.cwd);
|
|
90
|
+
filesystemOk=roots.some(r=>{let rr=path.resolve(r);return cwd===rr||cwd.startsWith(rr+path.sep)});
|
|
91
|
+
pathDenied=(rp?.filesystem?.denyPatterns??[]).some(pat=>new RegExp('^'+pat.replace(/\./g,'\\.').replace(/\*\*/g,'.*').replace(/\*/g,'[^/]*')+'$').test(cwd));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let shellRequired=false,shellCommandOk=true;
|
|
95
|
+
if(cmd.id==='RUN_SHELL'){
|
|
96
|
+
shellRequired=true;
|
|
97
|
+
let allow=rp?.exec?.allowCmds??[],deny=rp?.exec?.denyCmds??[],sc=cmd.payload?.cmd;
|
|
98
|
+
if(deny.includes(sc))shellCommandOk=false;
|
|
99
|
+
else shellCommandOk=allow.length===0||allow.includes(sc);
|
|
100
|
+
}
|
|
101
|
+
return{policyConfigured:hasP,requesterConfigured:!!rp,commandAllowed,adapterAllowed,filesystemRequired,filesystemRootsDefined,filesystemOk,pathDenied,shellRequired,shellCommandOk}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Key lifecycle ─────────────────────────────────────────────────────────────
|
|
105
|
+
var KEY_ID_RE=/^[A-Za-z0-9:_-]{3,128}$/;
|
|
106
|
+
function isValidKeyId(id){return typeof id==='string'&&KEY_ID_RE.test(id)&&id!=='default'}
|
|
107
|
+
|
|
108
|
+
function loadKeyStore(p){
|
|
109
|
+
let rp=path.resolve(p);
|
|
110
|
+
if(!fs.existsSync(rp))return{ok:false,reason:'KEY_STORE_MISSING',message:`Key store not found: ${rp}`,store:null};
|
|
111
|
+
try{
|
|
112
|
+
let s=JSON.parse(fs.readFileSync(rp,'utf-8'));
|
|
113
|
+
if(!s||typeof s!='object'||typeof s.trustedKeys!='object')return{ok:false,reason:'KEY_STORE_INVALID',message:`Invalid key store format: ${rp}`,store:null};
|
|
114
|
+
return{ok:true,reason:null,message:'Key store loaded',store:s}
|
|
115
|
+
}catch(e){return{ok:false,reason:'KEY_STORE_INVALID_JSON',message:`Unable to parse key store: ${e.message}`,store:null}}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveKeyFlags(keyStore,keyId,requesterId,now=new Date()){
|
|
119
|
+
let base={keyIdFormatValid:false,keyFound:false,keyNotDeprecated:false,keyRequesterMatches:false,keyNotBeforeOk:false,keyNotExpired:false,keyLifecycleFieldsPresent:false,publicKey:null};
|
|
120
|
+
if(!keyStore||!keyId)return base;
|
|
121
|
+
if(!isValidKeyId(keyId))return base;
|
|
122
|
+
base.keyIdFormatValid=true;
|
|
123
|
+
let entry=keyStore.trustedKeys?.[keyId];
|
|
124
|
+
if(!entry)return base;
|
|
125
|
+
base.keyFound=true;
|
|
126
|
+
base.keyNotDeprecated=!entry.deprecated;
|
|
127
|
+
base.keyRequesterMatches=!entry.requesterId||entry.requesterId===requesterId;
|
|
128
|
+
let nb=entry.notBefore||entry.validFrom,exp=entry.expiresAt||entry.validUntil;
|
|
129
|
+
base.keyLifecycleFieldsPresent=typeof nb==='string'&&typeof exp==='string';
|
|
130
|
+
if(base.keyLifecycleFieldsPresent){
|
|
131
|
+
let nd=new Date(nb),ed=new Date(exp);
|
|
132
|
+
if(!isNaN(nd.getTime())&&!isNaN(ed.getTime())&&nd<ed){base.keyNotBeforeOk=now>=nd;base.keyNotExpired=now<ed}
|
|
133
|
+
}
|
|
134
|
+
base.publicKey=entry.publicKey??null;
|
|
135
|
+
return base
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Policy version guard ──────────────────────────────────────────────────────
|
|
139
|
+
function parseVer(v){
|
|
140
|
+
if(typeof v==='number'&&Number.isFinite(v))return{ok:true,kind:'int',parts:[Math.floor(v)],raw:String(v)};
|
|
141
|
+
if(typeof v!=='string'||!v.trim())return{ok:false,reason:'POLICY_VERSION_INVALID',message:'Policy version required'};
|
|
142
|
+
let r=v.trim();
|
|
143
|
+
if(/^\d+$/.test(r))return{ok:true,kind:'int',parts:[Number(r)],raw:r};
|
|
144
|
+
let s=r.replace(/^v/i,'');
|
|
145
|
+
if(/^\d+(\.\d+){0,2}$/.test(s)){let p=s.split('.').map(Number);while(p.length<3)p.push(0);return{ok:true,kind:'semver',parts:p,raw:r}}
|
|
146
|
+
return{ok:false,reason:'POLICY_VERSION_INVALID',message:`Unsupported policy version format '${v}'`}
|
|
147
|
+
}
|
|
148
|
+
function cmpVer(a,b){let n=Math.max(a.parts.length,b.parts.length);for(let i=0;i<n;i++){let x=a.parts[i]??0,y=b.parts[i]??0;if(x>y)return 1;if(x<y)return-1}return 0}
|
|
149
|
+
function parseTs(v){
|
|
150
|
+
if(typeof v==='number'&&Number.isFinite(v))return{ok:true,epochSec:v>1e12?Math.floor(v/1e3):Math.floor(v)};
|
|
151
|
+
if(typeof v!=='string'||!v.trim())return{ok:false,reason:'POLICY_CREATED_AT_INVALID',message:'Policy createdAt required'};
|
|
152
|
+
let ms=Date.parse(v);return isNaN(ms)?{ok:false,reason:'POLICY_CREATED_AT_INVALID',message:`Invalid createdAt '${v}'`}:{ok:true,epochSec:Math.floor(ms/1e3)}
|
|
153
|
+
}
|
|
154
|
+
function readPolicyState(p){
|
|
155
|
+
if(!fs.existsSync(p))return{schemaVersion:'1',lastAccepted:null,updatedAt:null};
|
|
156
|
+
try{let s=JSON.parse(fs.readFileSync(p,'utf8'));if(!s||typeof s!=='object')throw new Error('invalid');return{schemaVersion:String(s.schemaVersion||'1'),lastAccepted:s.lastAccepted&&typeof s.lastAccepted==='object'?s.lastAccepted:null,updatedAt:s.updatedAt||null}}
|
|
157
|
+
catch(e){throw new Error(`Policy state at ${p} is corrupt: ${e.message}`)}
|
|
158
|
+
}
|
|
159
|
+
function validatePolicyVersionGuard({policyObj,statePath,persist=true,nowSec=Math.floor(Date.now()/1e3),maxSkewSec=31536000}){
|
|
160
|
+
let av=parseVer(policyObj?.version);if(!av.ok)return{ok:false,...av,updated:false};
|
|
161
|
+
let at2=parseTs(policyObj?.createdAt);if(!at2.ok)return{ok:false,...at2,updated:false};
|
|
162
|
+
if(Math.abs(nowSec-at2.epochSec)>maxSkewSec)return{ok:false,reason:'POLICY_CREATED_AT_SKEW_EXCEEDED',message:`Policy createdAt skew exceeds ${maxSkewSec}s`,updated:false};
|
|
163
|
+
let state;try{state=readPolicyState(statePath)}catch(e){return{ok:false,reason:'POLICY_STATE_CORRUPT',message:e.message,updated:false}}
|
|
164
|
+
let prev=state.lastAccepted,pv=null,pt2=null,cmp=0;
|
|
165
|
+
if(prev){pv=parseVer(prev.version);pt2=parseTs(prev.createdAt);
|
|
166
|
+
if(pv.ok&&pt2.ok){cmp=cmpVer(av,pv);
|
|
167
|
+
if(cmp<0)return{ok:false,reason:'POLICY_VERSION_REGRESSION',message:`Version regression: '${av.raw}' < '${pv.raw}'`,updated:false};
|
|
168
|
+
if(cmp===0&&at2.epochSec<pt2.epochSec)return{ok:false,reason:'POLICY_CREATED_AT_REGRESSION',message:'Policy createdAt must increase',updated:false};
|
|
169
|
+
if(cmp>0&&at2.epochSec<pt2.epochSec)return{ok:false,reason:'POLICY_CREATED_AT_REGRESSION',message:'Policy createdAt must increase with version',updated:false};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let changed=!prev||!pv?.ok||!pt2?.ok||cmp>0||(cmp===0&&at2.epochSec>pt2.epochSec);
|
|
173
|
+
if(persist&&changed)atomicWrite(statePath,JSON.stringify({schemaVersion:'1',lastAccepted:{version:policyObj.version,createdAt:policyObj.createdAt,environment:policyObj.environment||null},updatedAt:new Date().toISOString()},null,2),{encoding:'utf8'});
|
|
174
|
+
return{ok:true,reason:null,message:'Policy version guard passed',updated:changed}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Policy signature verification ─────────────────────────────────────────────
|
|
178
|
+
function verifyPolicySig({policyObj,keyStore,policySigPath='./config/policy.sig.json',allowUnsigned=false}){
|
|
179
|
+
let p=path.resolve(policySigPath);
|
|
180
|
+
if(!fs.existsSync(p))return allowUnsigned?{ok:true,skipped:true,reason:'POLICY_SIGNATURE_SKIPPED',message:`Policy sig not found: ${p} (allowed by flag)`}:{ok:false,skipped:false,reason:'POLICY_SIGNATURE_MISSING',message:`Policy sig not found: ${p}`};
|
|
181
|
+
let env;try{env=JSON.parse(fs.readFileSync(p,'utf-8'))}catch(e){return{ok:false,skipped:false,reason:'POLICY_SIGNATURE_INVALID',message:`Cannot parse policy sig: ${e.message}`}};
|
|
182
|
+
if(!env||env.alg!=='ed25519'||typeof env.keyId!=='string'||typeof env.sig!=='string')return{ok:false,skipped:false,reason:'POLICY_SIGNATURE_INVALID',message:'Policy sig must include {alg,keyId,sig}'};
|
|
183
|
+
if(!keyStore)return{ok:false,skipped:false,reason:'POLICY_SIGNER_KEY_STORE_UNAVAILABLE',message:'Key store required for policy sig verification'};
|
|
184
|
+
let kf=resolveKeyFlags(keyStore,env.keyId,undefined,new Date());
|
|
185
|
+
if(!kf.keyFound||!kf.keyNotExpired)return{ok:false,skipped:false,reason:'POLICY_SIGNER_NOT_TRUSTED',message:`Key '${env.keyId}' not trusted or expired`};
|
|
186
|
+
let v=verifyEd25519({payloadObj:policyObj,sigB64:env.sig,pubKeyB64:kf.publicKey});
|
|
187
|
+
return v.valid?{ok:true,skipped:false,reason:null,message:'Policy signature verified',keyId:env.keyId}:{ok:false,skipped:false,reason:'POLICY_SIGNATURE_INVALID',message:v.message}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Invariant gate ────────────────────────────────────────────────────────────
|
|
191
|
+
class InvariantGateError extends Error{constructor(msg,checks,failures){super(msg);this.name='InvariantGateError';this.checks=checks;this.failures=failures}}
|
|
192
|
+
function dirWritable(d){try{fs.accessSync(d,fs.constants.W_OK);return true}catch{try{fs.mkdirSync(d,{recursive:true});return true}catch{return false}}}
|
|
193
|
+
function runInvariantGate(paths,policy,keyStore){
|
|
194
|
+
let c={},f=[];
|
|
195
|
+
c.policy_structure=!!(policy&&policy.default==='DENY'&&policy.requesters&&typeof policy.requesters==='object'&&typeof policy.version!=='undefined');
|
|
196
|
+
if(!c.policy_structure)f.push('Policy missing required fields: version, requesters, default=DENY');
|
|
197
|
+
c.keys_available=!!(keyStore&&typeof keyStore==='object'&&Object.keys(keyStore).length>0);
|
|
198
|
+
if(!c.keys_available)f.push('No trusted keys loaded — provide config/keys.json');
|
|
199
|
+
for(let[k,p2]of[['audit_log_writable',paths.auditLog],['nonce_db_writable',paths.nonceDb],['rate_limit_writable',paths.rateLimit]]){c[k]=dirWritable(path.dirname(p2));if(!c[k])f.push(`${k} directory not writable: ${path.dirname(p2)}`)}
|
|
200
|
+
c.secret_key_present=!!paths.secretKey;if(!c.secret_key_present)f.push('secretKey not provided to createLBE()');
|
|
201
|
+
return{ok:f.length===0,checks:c,failures:f}
|
|
202
|
+
}
|
|
203
|
+
function assertInvariantGate(paths,policy,keyStore){
|
|
204
|
+
let r=runInvariantGate(paths,policy,keyStore);
|
|
205
|
+
if(!r.ok){let n=r.failures.length;throw new InvariantGateError(`Invariant gate: ${n} violation${n===1?'':'s'} — ${r.failures.join(' | ')}`,r.checks,r.failures)}
|
|
206
|
+
return r
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Logger ────────────────────────────────────────────────────────────────────
|
|
210
|
+
var LEVELS={DEBUG:0,INFO:1,WARN:2,ERROR:3};
|
|
211
|
+
function createLogger({level='INFO',maxHistory=500,silent=false}={}){
|
|
212
|
+
let threshold=LEVELS[level]??LEVELS.INFO,history=[];
|
|
213
|
+
function emit(lvl,scope,msg,meta){
|
|
214
|
+
let e={ts:new Date().toISOString(),level:lvl,scope,message:msg,...(meta!==undefined?{meta}:{})};
|
|
215
|
+
if(history.length>=maxHistory)history.shift();history.push(e);
|
|
216
|
+
if(!silent&&LEVELS[lvl]>=threshold)process.stderr.write((meta!==undefined?`[${lvl}] [${scope}] ${msg} ${JSON.stringify(meta)}`:`[${lvl}] [${scope}] ${msg}`)+'\n')
|
|
217
|
+
}
|
|
218
|
+
function scope(s){return{debug:(m,d)=>emit('DEBUG',s,m,d),info:(m,d)=>emit('INFO',s,m,d),warn:(m,d)=>emit('WARN',s,m,d),error:(m,d)=>emit('ERROR',s,m,d)}}
|
|
219
|
+
return{scope,debug:(m,d)=>emit('DEBUG','lbe',m,d),info:(m,d)=>emit('INFO','lbe',m,d),warn:(m,d)=>emit('WARN','lbe',m,d),error:(m,d)=>emit('ERROR','lbe',m,d),exportLogs:()=>[...history],clearHistory:()=>{history.length=0},get historyLength(){return history.length}}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Nonce / rate-limit IO helpers ─────────────────────────────────────────────
|
|
223
|
+
// WASM owns the logic. These helpers serialize/deserialize the text-format DB.
|
|
224
|
+
function loadNonceEntries(dbPath){
|
|
225
|
+
try{if(!fs.existsSync(dbPath))return[];let db=JSON.parse(fs.readFileSync(dbPath,'utf8'));return(db?.entries??[]).map(e=>`${e.key}:${e.timestamp}`)}
|
|
226
|
+
catch{return[]}
|
|
227
|
+
}
|
|
228
|
+
function saveNonceEntries(dbPath,text){
|
|
229
|
+
let entries=text.split('\n').filter(Boolean).map(line=>{let i=line.lastIndexOf(':');return{key:line.slice(0,i),timestamp:parseInt(line.slice(i+1),10)||0}});
|
|
230
|
+
atomicWrite(dbPath,JSON.stringify({entries},null,2),{encoding:'utf8'})
|
|
231
|
+
}
|
|
232
|
+
function loadRateEntries(dbPath){
|
|
233
|
+
try{if(!fs.existsSync(dbPath))return[];let db=JSON.parse(fs.readFileSync(dbPath,'utf8'));return(db?.entries??[]).map(e=>`${e.requesterId}:${e.timestamp}`)}
|
|
234
|
+
catch{return[]}
|
|
235
|
+
}
|
|
236
|
+
function saveRateEntries(dbPath,text){
|
|
237
|
+
let entries=text.split('\n').filter(Boolean).map(line=>{let i=line.lastIndexOf(':');return{requesterId:line.slice(0,i),timestamp:parseInt(line.slice(i+1),10)||0}});
|
|
238
|
+
atomicWrite(dbPath,JSON.stringify({entries},null,2),{encoding:'utf8'})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Validation — thin orchestrator; all decisions delegate to WASM ────────────
|
|
242
|
+
function validate({commandObj,pubKeyB64,keyStore,nonceDbPath,rateLimitDbPath,policy,policyStatePath}){
|
|
243
|
+
let result={valid:false,commandId:commandObj?.commandId,checks:{},errors:[]};
|
|
244
|
+
let nowSec=Math.floor(Date.now()/1e3);
|
|
245
|
+
let maxClockSkewSec=Number.isFinite(policy?.security?.maxClockSkewSec)?policy.security.maxClockSkewSec:600;
|
|
246
|
+
|
|
247
|
+
// Policy version guard (meta-check on the policy itself, not the command)
|
|
248
|
+
if(policyStatePath&&policy?.version!==undefined){
|
|
249
|
+
try{
|
|
250
|
+
let vg=validatePolicyVersionGuard({policyObj:policy,statePath:policyStatePath});
|
|
251
|
+
result.checks.policyVersion=vg.ok;
|
|
252
|
+
if(!vg.ok){result.errors.push({type:'POLICY_VERSION_INVALID',message:vg.message});return result}
|
|
253
|
+
}catch{result.checks.policyVersion=true}
|
|
254
|
+
}else{result.checks.policyVersion=true}
|
|
255
|
+
|
|
256
|
+
// Extract structured flag sets for WASM
|
|
257
|
+
let sf=schemaFlags(commandObj);
|
|
258
|
+
let keyId=commandObj?.signature?.keyId;
|
|
259
|
+
let kf=resolveKeyFlags(keyStore,keyId,commandObj?.requesterId,new Date());
|
|
260
|
+
|
|
261
|
+
// Ed25519 signature verification (stays in JS — well-known algorithm via tweetnacl)
|
|
262
|
+
let signatureValid=false,effectivePubKey=kf.publicKey||pubKeyB64;
|
|
263
|
+
if(effectivePubKey){
|
|
264
|
+
let body={...commandObj};delete body.signature;
|
|
265
|
+
signatureValid=verifyEd25519({payloadObj:body,sigB64:commandObj?.signature?.sig,pubKeyB64:effectivePubKey}).valid;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Rate limit — WASM sliding-window logic, state stored in JS file
|
|
269
|
+
let rateLimitOk=true,rateLimitRetryAfterSec=0;
|
|
270
|
+
if(signatureValid&&rateLimitDbPath){
|
|
271
|
+
let rateCfg=policy?.requesters?.[commandObj.requesterId]?.rateLimit||{};
|
|
272
|
+
let dflt=policy?.security?.defaultRateLimit||{};
|
|
273
|
+
let rr=checkRateLimit({windowSec:rateCfg.windowSec??dflt.windowSec??60,maxRequests:rateCfg.maxRequests??dflt.maxRequests??30,nowSec,requesterId:commandObj.requesterId,existingEntries:loadRateEntries(rateLimitDbPath)});
|
|
274
|
+
rateLimitOk=rr.ok;rateLimitRetryAfterSec=rr.retryAfterSec;
|
|
275
|
+
saveRateEntries(rateLimitDbPath,rr.updatedEntriesText??'');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Nonce check — WASM dedup logic, state stored in JS file
|
|
279
|
+
let nonceOk=true;
|
|
280
|
+
let nonceKey=`${commandObj?.requesterId}|${commandObj?.sessionId}|${commandObj?.nonce}`;
|
|
281
|
+
if(signatureValid&&rateLimitOk&&nonceDbPath){
|
|
282
|
+
let nr2=checkNonce({ttlSec:3600,nowSec,newKey:nonceKey,existingEntries:loadNonceEntries(nonceDbPath)});
|
|
283
|
+
nonceOk=nr2.ok;
|
|
284
|
+
if(nr2.ok)saveNonceEntries(nonceDbPath,nr2.updatedEntriesText??'');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Policy flags
|
|
288
|
+
let pf=policyFlags(policy,commandObj??{});
|
|
289
|
+
|
|
290
|
+
// WASM pipeline — single call owns all governance decisions
|
|
291
|
+
let pipe=runValidationPipeline({...sf,cmdTimestamp:commandObj?.timestamp??0,nowSec,maxClockSkewSec,...kf,signatureValid,rateLimitOk,rateLimitRetryAfterSec,nonceOk,...pf});
|
|
292
|
+
|
|
293
|
+
// Map pipeline result to the checks surface
|
|
294
|
+
result.checks.schema=pipe.ok||pipe.stage>0;
|
|
295
|
+
result.checks.timestamp=pipe.ok||pipe.stage>1;
|
|
296
|
+
result.checks.keyId=pipe.ok||pipe.stage>2;
|
|
297
|
+
result.checks.signature=pipe.ok||pipe.stage>3;
|
|
298
|
+
result.checks.rateLimit=pipe.ok||pipe.stage>4;
|
|
299
|
+
result.checks.nonce=pipe.ok||pipe.stage>5;
|
|
300
|
+
result.checks.policy=pipe.ok;
|
|
301
|
+
|
|
302
|
+
if(!pipe.ok){
|
|
303
|
+
let s=pipe.stageLabel;
|
|
304
|
+
if(s==='schema'){result.checks.schema=false;result.errors.push({type:'SCHEMA_ERROR',message:pipe.schemaError||'Schema invalid'})}
|
|
305
|
+
else if(s==='timestamp'){result.checks.timestamp=false;result.errors.push({type:'TIMESTAMP_SKEW_EXCEEDED',message:`Command timestamp skew ${pipe.skewSec}s exceeds allowed ${maxClockSkewSec}s`})}
|
|
306
|
+
else if(s==='key'){result.checks.keyId=false;result.checks.signature=false;let kr=pipe.keyReason||'KEY_ERROR';result.errors.push({type:kr,message:kr})}
|
|
307
|
+
else if(s==='signature'){result.checks.signature=false;result.errors.push({type:'SIGNATURE_INVALID',message:effectivePubKey?'Signature verification failed':'No public key available'})}
|
|
308
|
+
else if(s==='rate_limit'){result.checks.rateLimit=false;result.errors.push({type:'RATE_LIMIT_EXCEEDED',message:`Rate limit exceeded. Retry after ${pipe.retryAfterSec}s`})}
|
|
309
|
+
else if(s==='nonce'){result.checks.nonce=false;result.errors.push({type:'REPLAY_NONCE',message:'Nonce has already been used'})}
|
|
310
|
+
else if(s==='policy'&&pipe.policyResult){result.checks.policy=false;result.errors.push({type:pipe.policyResult.reason,message:pipe.policyResult.message})}
|
|
311
|
+
else result.errors.push({type:'VALIDATION_FAILED',message:`Failed at stage: ${s}`});
|
|
312
|
+
return result
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
result.valid=true;
|
|
316
|
+
result.risk=classifyRisk(commandObj.id,commandObj.payload?.cmd==='rm');
|
|
317
|
+
result.message='Command validation successful';
|
|
318
|
+
return result
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── Audit log ─────────────────────────────────────────────────────────────────
|
|
322
|
+
// Hash-chain computation delegates to WASM (SHA-256). File IO stays in JS.
|
|
323
|
+
function getLastHash(auditPath){
|
|
324
|
+
try{
|
|
325
|
+
if(!fs.existsSync(auditPath))return'GENESIS';
|
|
326
|
+
let lines=fs.readFileSync(auditPath,'utf8').trim().split('\n').filter(Boolean);
|
|
327
|
+
if(!lines.length)return'GENESIS';
|
|
328
|
+
return JSON.parse(lines[lines.length-1]).hash||'GENESIS'
|
|
329
|
+
}catch{return'GENESIS'}
|
|
330
|
+
}
|
|
331
|
+
function appendAuditEntry(auditPath,entry){
|
|
332
|
+
let dir=path.dirname(auditPath);fs.mkdirSync(dir,{recursive:true});
|
|
333
|
+
return withLock(auditPath,()=>{
|
|
334
|
+
let prevHash=getLastHash(auditPath);
|
|
335
|
+
let body={...entry,prevHash,timestamp:new Date().toISOString()};delete body.hash;
|
|
336
|
+
let entryJson=JSON.stringify(body);
|
|
337
|
+
let hash=computeAuditHash(prevHash,entryJson);
|
|
338
|
+
let line=JSON.stringify({...body,hash})+'\n';
|
|
339
|
+
let existing=fs.existsSync(auditPath)?fs.readFileSync(auditPath,'utf8'):'';
|
|
340
|
+
atomicWrite(auditPath,existing+line,{encoding:'utf8'});
|
|
341
|
+
return{success:true,hash,prevHash,message:'Audit entry appended'}
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Adapters (readable — execution only, no governance) ───────────────────────
|
|
346
|
+
async function noopAdapter(cmd){
|
|
347
|
+
return{adapter:'noop',commandId:cmd.commandId,command:cmd.id,status:'completed',output:`[NOOP] Would execute: ${cmd.id} on adapter: ${cmd.payload?.adapter}`,exitCode:0,timestamp:new Date().toISOString()}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
import{spawnSync}from'child_process';
|
|
351
|
+
function parseArgs(args){
|
|
352
|
+
if(args===undefined)return{ok:true,args:[]};
|
|
353
|
+
if(!Array.isArray(args))return{ok:false,error:'payload.args must be an array'};
|
|
354
|
+
let out=[];for(let a of args){if(typeof a!=='string'&&typeof a!=='number'&&typeof a!=='boolean')return{ok:false,error:'payload.args may only contain string, number, or boolean'};out.push(String(a))}
|
|
355
|
+
return{ok:true,args:out}
|
|
356
|
+
}
|
|
357
|
+
async function shellAdapter(cmd,_policy,rp){
|
|
358
|
+
let t=cmd.payload,allowed=rp?.exec?.allowCmds??[],denied=rp?.exec?.denyCmds??[];
|
|
359
|
+
if(t.adapter!=='shell')return{adapter:'shell',commandId:cmd.commandId,status:'error',error:'Adapter mismatch',exitCode:1};
|
|
360
|
+
if(denied.includes(t.cmd))return{adapter:'shell',commandId:cmd.commandId,status:'blocked',error:`Command '${t.cmd}' is denied`,exitCode:2};
|
|
361
|
+
if(allowed.length>0&&!allowed.includes(t.cmd))return{adapter:'shell',commandId:cmd.commandId,status:'blocked',error:`Command '${t.cmd}' not in allowlist`,exitCode:2};
|
|
362
|
+
let roots=rp?.filesystem?.roots??[];
|
|
363
|
+
if(!roots.some(r=>{let rp2=path.resolve(r),cwd2=path.resolve(t.cwd);return cwd2===rp2||cwd2.startsWith(rp2+path.sep)}))
|
|
364
|
+
return{adapter:'shell',commandId:cmd.commandId,status:'blocked',error:`CWD '${t.cwd}' not authorized`,exitCode:2};
|
|
365
|
+
let pa=parseArgs(t.args);if(!pa.ok)return{adapter:'shell',commandId:cmd.commandId,status:'blocked',error:pa.error,exitCode:2};
|
|
366
|
+
try{
|
|
367
|
+
let r=spawnSync(t.cmd,pa.args,{cwd:t.cwd,timeout:30000,encoding:'utf8',maxBuffer:1024*1024,stdio:['pipe','pipe','pipe'],shell:false});
|
|
368
|
+
if(r.error)throw r.error;
|
|
369
|
+
let out=`${r.stdout||''}${r.stderr||''}`,code=r.status??1;
|
|
370
|
+
if(code!==0)return{adapter:'shell',commandId:cmd.commandId,command:t.cmd,status:'error',error:out||`Exit ${code}`,exitCode:code,timestamp:new Date().toISOString()};
|
|
371
|
+
return{adapter:'shell',commandId:cmd.commandId,command:t.cmd,status:'completed',output:out,exitCode:0,timestamp:new Date().toISOString()}
|
|
372
|
+
}catch(e){return{adapter:'shell',commandId:cmd.commandId,command:t.cmd,status:'error',error:e.message,exitCode:1,timestamp:new Date().toISOString()}}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
var MAX_READ_BYTES=10*1024*1024;
|
|
376
|
+
function absTarget(t,cwd){return t?path.isAbsolute(t)?path.resolve(t):path.resolve(cwd||process.cwd(),t):null}
|
|
377
|
+
function inRoots(p2,roots){return roots.some(r=>{let rr=path.resolve(r);return p2===rr||p2.startsWith(rr+path.sep)})}
|
|
378
|
+
function matchDeny(p2,patterns){for(let pat of patterns||[])if(new RegExp('^'+pat.replace(/\./g,'\\.').replace(/\*\*/g,'.*').replace(/\*/g,'[^/\\\\]*')+'$').test(p2))return pat;return null}
|
|
379
|
+
function fileBlocked(cmd,code,msg){return{adapter:'file',commandId:cmd.commandId,status:'blocked',errorCode:code,error:msg,exitCode:2}}
|
|
380
|
+
function fileError(cmd,code,msg,bk=null){return{adapter:'file',commandId:cmd.commandId,status:'error',errorCode:code,error:msg,backup:bk?{path:bk.backupPath,existed:bk.existed,hash:bk.hash,createdAt:bk.createdAt}:null,exitCode:1}}
|
|
381
|
+
function makeBackup(target,backupDir){
|
|
382
|
+
fs.mkdirSync(backupDir,{recursive:true});
|
|
383
|
+
let tp=path.resolve(target),existed=fs.existsSync(tp),data=existed?fs.readFileSync(tp):null;
|
|
384
|
+
let hash=data?crypto.createHash('sha256').update(data).digest('hex'):null;
|
|
385
|
+
let bname=path.basename(tp).replace(/[^a-zA-Z0-9._-]/g,'_');
|
|
386
|
+
let bpath=existed?path.join(backupDir,`${Date.now()}-${hash.slice(0,8)}-${bname}`):null;
|
|
387
|
+
if(existed&&data!==null)atomicWrite(bpath,data);
|
|
388
|
+
return{originalPath:tp,backupPath:bpath,existed,hash,createdAt:new Date().toISOString()}
|
|
389
|
+
}
|
|
390
|
+
function restoreBackup(bk){
|
|
391
|
+
if(!bk)return{restored:false,error:'No backup metadata'};
|
|
392
|
+
if(!bk.existed)try{fs.existsSync(bk.originalPath)&&fs.unlinkSync(bk.originalPath);return{restored:true,action:'deleted'}}catch(e){return{restored:false,error:e.message}}
|
|
393
|
+
if(!bk.backupPath||!fs.existsSync(bk.backupPath))return{restored:false,error:'Backup file missing: '+bk.backupPath};
|
|
394
|
+
try{atomicWrite(bk.originalPath,fs.readFileSync(bk.backupPath));return{restored:true,action:'restored'}}catch(e){return{restored:false,error:e.message}}
|
|
395
|
+
}
|
|
396
|
+
async function fileAdapter(cmd,_policy,rp){
|
|
397
|
+
let t=cmd.payload,action=t.action,cwd=t.cwd||process.cwd(),target=absTarget(t.target,cwd);
|
|
398
|
+
if(!action)return fileBlocked(cmd,'FILE_NO_ACTION','payload.action required');
|
|
399
|
+
if(!target&&action!=='noop')return fileBlocked(cmd,'FILE_NO_TARGET','payload.target required');
|
|
400
|
+
let roots=rp?.filesystem?.roots??[];if(roots.length===0)return fileBlocked(cmd,'FILE_NO_ROOTS','No filesystem roots defined');
|
|
401
|
+
if(!inRoots(target,roots))return fileBlocked(cmd,'FILE_OUTSIDE_ROOT',`'${target}' outside allowed roots`);
|
|
402
|
+
let denied=matchDeny(target,rp?.filesystem?.denyPatterns);if(denied)return fileBlocked(cmd,'FILE_PATH_DENIED',`'${target}' matches deny pattern: ${denied}`);
|
|
403
|
+
if(action==='read'){
|
|
404
|
+
if(!fs.existsSync(target))return fileError(cmd,'FILE_NOT_FOUND',`Not found: ${target}`);
|
|
405
|
+
try{let stat=fs.statSync(target);if(stat.size>MAX_READ_BYTES)return fileError(cmd,'FILE_TOO_LARGE','File exceeds 10 MB');let out=fs.readFileSync(target,'utf8');return{adapter:'file',action,commandId:cmd.commandId,status:'completed',target,output:out,bytesRead:stat.size,exitCode:0}}
|
|
406
|
+
catch(e){return fileError(cmd,'FILE_READ_ERROR',e.message)}
|
|
407
|
+
}
|
|
408
|
+
let bk=null;try{bk=makeBackup(target,rp?.backupDir||path.join(os.homedir(),'.lbe','backups'))}catch{}
|
|
409
|
+
if(action==='write'||action==='patch'){
|
|
410
|
+
if(t.content==null)return fileError(cmd,`FILE_MISSING_CONTENT`,'payload.content required');
|
|
411
|
+
try{atomicWrite(target,t.content,{encoding:'utf8'});return{adapter:'file',action,commandId:cmd.commandId,status:'completed',target,backup:bk?{path:bk.backupPath,existed:bk.existed,hash:bk.hash}:null,output:`Wrote ${Buffer.byteLength(t.content,'utf8')} bytes to ${target}`,exitCode:0}}
|
|
412
|
+
catch(e){restoreBackup(bk);return fileError(cmd,`FILE_${action.toUpperCase()}_ERROR`,e.message,bk)}
|
|
413
|
+
}
|
|
414
|
+
if(action==='delete'){
|
|
415
|
+
if(!fs.existsSync(target))return fileError(cmd,'FILE_NOT_FOUND',`Not found: ${target}`);
|
|
416
|
+
try{fs.unlinkSync(target);return{adapter:'file',action,commandId:cmd.commandId,status:'completed',target,backup:bk?{path:bk.backupPath,existed:bk.existed,hash:bk.hash}:null,output:`Deleted ${target}`,exitCode:0}}
|
|
417
|
+
catch(e){return fileError(cmd,'FILE_DELETE_ERROR',e.message,bk)}
|
|
418
|
+
}
|
|
419
|
+
return fileBlocked(cmd,'FILE_UNKNOWN_ACTION',`Unknown action: '${action}'`)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function observerAdapter(cmd){
|
|
423
|
+
if(!(cmd.id||'').toUpperCase().startsWith('OBSERVE'))return{adapter:'observer',commandId:cmd.commandId,status:'error',error:`Observer adapter only handles OBSERVE_* commands`,exitCode:1};
|
|
424
|
+
let{source,context,issueType,description,severity,metadata}=cmd.payload||{};
|
|
425
|
+
if(!issueType||!description)return{adapter:'observer',commandId:cmd.commandId,status:'error',error:'Observer payload must include issueType and description',exitCode:1};
|
|
426
|
+
let levels=['low','medium','high','critical'];
|
|
427
|
+
if(severity&&!levels.includes(severity))return{adapter:'observer',commandId:cmd.commandId,status:'error',error:`Invalid severity '${severity}'`,exitCode:1};
|
|
428
|
+
return{adapter:'observer',commandId:cmd.commandId,status:'recorded',timestamp:new Date().toISOString(),requesterId:cmd.requesterId,observation:{source:source||'unknown',context:context||'unknown',issueType,description,severity:severity||'info',metadata:metadata||{}},exitCode:0}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
var ADAPTERS={noop:noopAdapter,shell:shellAdapter,file:fileAdapter,observer:observerAdapter};
|
|
432
|
+
var ADAPTER_NAMES=Object.keys(ADAPTERS);
|
|
433
|
+
async function runAdapter(name,cmd,policy,rp){
|
|
434
|
+
let fn=ADAPTERS[name];
|
|
435
|
+
if(!fn)return{adapter:name,commandId:cmd.commandId,status:'error',error:`Adapter '${name}' not found`,exitCode:1};
|
|
436
|
+
try{return await fn(cmd,policy,rp)}catch(e){return{adapter:name,commandId:cmd.commandId,status:'error',error:`Adapter execution failed: ${e.message}`,exitCode:9}}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Approval token store ──────────────────────────────────────────────────────
|
|
440
|
+
var _checkpointStore=null;
|
|
441
|
+
class CheckpointStore{
|
|
442
|
+
constructor(p){this.dbPath=p||path.resolve('data/checkpoints.db.json');this.store={checkpoints:{},tokens:{}};this._load()}
|
|
443
|
+
_load(){let r=readJson(this.dbPath);if(r){this.store=r;this.store.checkpoints=this.store.checkpoints||{};this.store.tokens=this.store.tokens||{}}}
|
|
444
|
+
_save(){atomicWrite(this.dbPath,JSON.stringify(this.store,null,2),{encoding:'utf8'})}
|
|
445
|
+
saveToken(id,data){this.store.tokens[id]={tokenId:id,...data,createdAt:Date.now()};this._save()}
|
|
446
|
+
getToken(id){return this.store.tokens[id]||null}
|
|
447
|
+
removeToken(id){if(this.store.tokens[id]){delete this.store.tokens[id];this._save();return true}return false}
|
|
448
|
+
}
|
|
449
|
+
function getCheckpointStore(p){return _checkpointStore||(_checkpointStore=new CheckpointStore(p))}
|
|
450
|
+
|
|
451
|
+
var _approvalGate=null;
|
|
452
|
+
class ApprovalGate{
|
|
453
|
+
constructor(p){this.store=getCheckpointStore(p);this._pending=new Map()}
|
|
454
|
+
createToken(jobId,ctx={}){
|
|
455
|
+
let id=crypto.randomBytes(16).toString('hex');
|
|
456
|
+
this.store.saveToken(id,{jobId,context:ctx,status:'pending',expiresAt:Date.now()+86400000});
|
|
457
|
+
return id
|
|
458
|
+
}
|
|
459
|
+
awaitApproval(id){
|
|
460
|
+
let t=this.store.getToken(id);
|
|
461
|
+
if(!t)return Promise.reject(new Error(`Approval token ${id} not found`));
|
|
462
|
+
if(t.status!=='pending')return Promise.reject(new Error(`Token ${id} not pending (${t.status})`));
|
|
463
|
+
if(Date.now()>t.expiresAt){this.store.removeToken(id);return Promise.reject(new Error(`Token ${id} expired`))}
|
|
464
|
+
return new Promise((res,rej)=>{this._pending.set(id,{resolve:res,reject:rej})})
|
|
465
|
+
}
|
|
466
|
+
approve(id,data={}){
|
|
467
|
+
let t=this.store.getToken(id);if(!t)throw new Error('Token not found');if(t.status!=='pending')throw new Error('Token not pending');
|
|
468
|
+
this.store.saveToken(id,{...t,status:'approved',approverData:data,resolvedAt:Date.now()});
|
|
469
|
+
let p=this._pending.get(id);if(p){p.resolve({approved:true,approverData:data});this._pending.delete(id)}return true
|
|
470
|
+
}
|
|
471
|
+
deny(id,reason='Manually denied'){
|
|
472
|
+
let t=this.store.getToken(id);if(!t)throw new Error('Token not found');if(t.status!=='pending')throw new Error('Token not pending');
|
|
473
|
+
this.store.saveToken(id,{...t,status:'denied',reason,resolvedAt:Date.now()});
|
|
474
|
+
let p=this._pending.get(id);if(p){p.reject(new Error(`Approval denied: ${reason}`));this._pending.delete(id)}return true
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function getApprovalGate(p){return _approvalGate||(_approvalGate=new ApprovalGate(p))}
|
|
478
|
+
|
|
479
|
+
// ── State directory resolver ──────────────────────────────────────────────────
|
|
480
|
+
function resolveStateDir(rootDir,state){
|
|
481
|
+
let rp=path.resolve(rootDir||process.cwd());
|
|
482
|
+
if(!state||state==='local'){let h=crypto.createHash('sha256').update(rp).digest('hex').slice(0,16);return path.join(os.homedir(),'.lbe','workspaces',h)}
|
|
483
|
+
if(state==='workspace')return path.join(rp,'.lbe');
|
|
484
|
+
if(state&&typeof state==='object'&&state.adapter)return null;
|
|
485
|
+
throw new Error(`createLBE: unknown state option: ${JSON.stringify(state)}`)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Risk helpers ──────────────────────────────────────────────────────────────
|
|
489
|
+
var INTENT_TO_CMD={patch_file:'PATCH_FILE',write_file:'WRITE_FILE',read_file:'READ_FILE',delete_file:'DELETE_FILE',run_shell:'RUN_SHELL',echo:'ECHO'};
|
|
490
|
+
var INTENT_TO_ADAPTER={PATCH_FILE:'file',WRITE_FILE:'file',READ_FILE:'file',DELETE_FILE:'file',RUN_SHELL:'shell',ECHO:'noop'};
|
|
491
|
+
var HIGH_RISK=new Set(['HIGH','CRITICAL']);
|
|
492
|
+
function needsApproval(risk,rp){
|
|
493
|
+
if(!rp?.requireApproval)return false;
|
|
494
|
+
let r=rp.requireApproval;
|
|
495
|
+
if(r===true)return true;
|
|
496
|
+
if(Array.isArray(r))return r.includes(risk)||r.includes('*')||(HIGH_RISK.has(risk)&&r.includes('HIGH+'));
|
|
497
|
+
return false
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
501
|
+
function deepFreeze(obj){
|
|
502
|
+
if(obj===null||typeof obj!=='object'||Object.isFrozen(obj))return obj;
|
|
503
|
+
Object.freeze(obj);
|
|
504
|
+
for(let k of Object.getOwnPropertyNames(obj)){let v=obj[k];if(typeof v==='object'&&v!==null&&!Object.isFrozen(v))deepFreeze(v)}
|
|
505
|
+
return obj
|
|
506
|
+
}
|
|
507
|
+
function payloadHash(obj){return crypto.createHash('sha256').update(JSON.stringify(obj)).digest('hex')}
|
|
508
|
+
|
|
509
|
+
// ── createLBE ─────────────────────────────────────────────────────────────────
|
|
510
|
+
export function createLBE(options={}){
|
|
511
|
+
// Auto-provision keypair when rootDir is provided without secretKey
|
|
512
|
+
if(options.rootDir&&!options.secretKey){
|
|
513
|
+
let kp=generateKeyPair(),kid=options.keyId||'sdk-auto-key';
|
|
514
|
+
options={defaultActor:'agent:sdk',logLevel:'WARN',...options,secretKey:kp.secretKey,keyId:kid,
|
|
515
|
+
keyStore:options.keyStore||makeKeyStore({publicKey:kp.publicKey,keyId:kid}),
|
|
516
|
+
policy:options.policy||{version:1,default:'DENY',requesters:{'agent:sdk':{allowCommands:['write_file','read_file','patch_file','delete_file'],allowAdapters:['file'],filesystem:{roots:[options.rootDir],denyPatterns:['*.key','*.env','*.secret']}}}}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
let{secretKey,keyId='sdk-key-v1',sessionId,defaultActor='agent:sdk',policy:inlinePolicy,keyStore:inlineKeyStore,
|
|
520
|
+
policyPath,keysStorePath,policySigPath,policyStatePath,nonceDbPath,rateLimitDbPath,auditLogPath,backupDir,
|
|
521
|
+
allowUnsignedPolicy=false,state='local',rootDir,
|
|
522
|
+
logLevel=process.env.LBE_LOG_LEVEL||'INFO',logSilent=process.env.LBE_LOG_SILENT==='1'}=options;
|
|
523
|
+
|
|
524
|
+
let sd=resolveStateDir(rootDir,state);
|
|
525
|
+
let paths={
|
|
526
|
+
secretKey,
|
|
527
|
+
policy:policyPath||path.resolve('config/policy.default.json'),
|
|
528
|
+
keys:keysStorePath||path.resolve('config/keys.json'),
|
|
529
|
+
policySig:policySigPath||path.resolve('config/policy.sig.json'),
|
|
530
|
+
policyState:policyStatePath||path.join(sd,'policy.state.json'),
|
|
531
|
+
nonceDb:nonceDbPath||path.join(sd,'nonce.db.json'),
|
|
532
|
+
rateLimit:rateLimitDbPath||path.join(sd,'rate-limit.db.json'),
|
|
533
|
+
auditLog:auditLogPath||path.join(sd,'audit.log.jsonl'),
|
|
534
|
+
backupDir:backupDir||path.join(sd,'backups'),
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
let log=createLogger({level:logLevel,silent:logSilent});
|
|
538
|
+
let exec_log=log.scope('Executor'),val_log=log.scope('Validator'),pol_log=log.scope('Policy');
|
|
539
|
+
|
|
540
|
+
async function execute({actor,intent,target,content,args=[],transaction={}}){
|
|
541
|
+
let tx={validate:true,backup:true,rollbackOnFailure:true,audit:true,...transaction};
|
|
542
|
+
exec_log.info('execute() called',{actor,intent,target});
|
|
543
|
+
|
|
544
|
+
// Load policy
|
|
545
|
+
let policy;
|
|
546
|
+
if(inlinePolicy){policy=inlinePolicy;pol_log.debug('Using inline policy',{version:policy.version})}
|
|
547
|
+
else{try{policy=JSON.parse(fs.readFileSync(paths.policy,'utf8'));pol_log.debug('Policy loaded',{version:policy.version})}
|
|
548
|
+
catch(e){pol_log.error('Policy load failed',{error:e.message});return{ok:false,stage:'policy_load',error:'POLICY_LOAD_FAILED',message:e.message}}}
|
|
549
|
+
|
|
550
|
+
// Load key store
|
|
551
|
+
let keyStore;
|
|
552
|
+
if(inlineKeyStore){keyStore=inlineKeyStore;val_log.debug('Using inline keyStore')}
|
|
553
|
+
else{let kr=loadKeyStore(paths.keys);keyStore=kr.ok?kr.store:null;val_log.debug('Keys loaded',{ok:kr.ok})}
|
|
554
|
+
|
|
555
|
+
deepFreeze(policy);if(keyStore)deepFreeze(keyStore);
|
|
556
|
+
|
|
557
|
+
// Invariant gate
|
|
558
|
+
try{let ig=assertInvariantGate(paths,policy,keyStore);val_log.debug('Invariant gate passed',ig.checks)}
|
|
559
|
+
catch(e){if(e instanceof InvariantGateError)return{ok:false,stage:'invariant_gate',error:'INVARIANT_GATE_FAILED',message:e.message,checks:e.checks,failures:e.failures};throw e}
|
|
560
|
+
|
|
561
|
+
// Policy signature check (only for file-loaded policy)
|
|
562
|
+
if(!inlinePolicy){
|
|
563
|
+
let ps=verifyPolicySig({policyObj:policy,keyStore,policySigPath:paths.policySig,allowUnsigned:allowUnsignedPolicy});
|
|
564
|
+
if(!ps.ok){pol_log.error('Policy sig invalid',{reason:ps.reason});return{ok:false,stage:'policy_sig',error:ps.reason,message:ps.message}}
|
|
565
|
+
let vg=validatePolicyVersionGuard({policyObj:policy,statePath:paths.policyState});
|
|
566
|
+
if(!vg.ok){pol_log.error('Policy version guard failed',{reason:vg.reason});return{ok:false,stage:'policy_version',error:vg.reason,message:vg.message}}
|
|
567
|
+
pol_log.debug('Policy sig and version valid')
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if(!secretKey)return{ok:false,stage:'sign',error:'NO_SECRET_KEY',message:'createLBE requires secretKey'};
|
|
571
|
+
|
|
572
|
+
// Build and sign command
|
|
573
|
+
let nowSec=Math.floor(Date.now()/1e3),nonce=crypto.randomBytes(32).toString('hex');
|
|
574
|
+
let sid=sessionId||`sdk-${Date.now()}`,commandId=crypto.randomUUID();
|
|
575
|
+
let cmdName=INTENT_TO_CMD[intent]||intent.toUpperCase().replace(/-/g,'_');
|
|
576
|
+
let adapter=INTENT_TO_ADAPTER[cmdName]||'noop';
|
|
577
|
+
exec_log.debug('Command built',{commandId,cmd:cmdName,adapter});
|
|
578
|
+
|
|
579
|
+
let body={id:cmdName,commandId,requesterId:actor,sessionId:sid,timestamp:nowSec,nonce,requires:['policy','signature'],
|
|
580
|
+
payload:{adapter,action:intent.includes('_')?intent.split('_')[0]:intent,target:target?path.resolve(target):null,content:content||null,args,cwd:target?path.dirname(path.resolve(target)):process.cwd()}};
|
|
581
|
+
let signed=signPayload({payloadObj:body,secretKeyB64:secretKey});
|
|
582
|
+
if(signed.error){exec_log.error('Signing failed',{error:signed.error});return{ok:false,stage:'sign',commandId,error:'SIGN_FAILED',message:signed.error}}
|
|
583
|
+
let cmd={...body,signature:{alg:'ed25519',keyId,sig:signed.signature}};
|
|
584
|
+
|
|
585
|
+
// Validate — thin JS orchestrator + WASM decisions
|
|
586
|
+
let val=validate({commandObj:cmd,keyStore,nonceDbPath:paths.nonceDb,rateLimitDbPath:paths.rateLimit,policy,policyStatePath:inlinePolicy?null:paths.policyState});
|
|
587
|
+
if(!val.valid){
|
|
588
|
+
val_log.warn('Validation failed',{error:val.errors[0]?.type,checks:val.checks});
|
|
589
|
+
if(tx.audit)appendAuditEntry(paths.auditLog,{commandId,status:'rejected',requesterId:actor,payloadHash:payloadHash(cmd),reason:val.errors[0]?.type,intent});
|
|
590
|
+
return{ok:false,stage:'validate',commandId,error:val.errors[0]?.type,message:val.errors[0]?.message,checks:val.checks,operationLog:log.exportLogs()}
|
|
591
|
+
}
|
|
592
|
+
val_log.info('Validation passed',{risk:val.risk,checks:val.checks});
|
|
593
|
+
|
|
594
|
+
// Approval gate
|
|
595
|
+
let risk=val.risk||'LOW';
|
|
596
|
+
let rp=policy.requesters?.[actor];
|
|
597
|
+
if(needsApproval(risk,rp)){
|
|
598
|
+
exec_log.warn('Approval required',{risk,commandId});
|
|
599
|
+
let token=getApprovalGate(paths.policyState).createToken(commandId,{actor,intent,target,risk,commandId:cmdName});
|
|
600
|
+
return{ok:false,stage:'approval_pending',commandId,approvalToken:token,risk,message:`${risk} risk operation requires approval. Token: ${token}`,operationLog:log.exportLogs()}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Backup
|
|
604
|
+
let bk=null;
|
|
605
|
+
if(tx.backup&&target){
|
|
606
|
+
let isWrite=['write','patch','delete'].includes(body.payload.action);
|
|
607
|
+
try{bk=makeBackup(path.resolve(target),paths.backupDir);exec_log.debug('Backup created',{existed:bk.existed})}
|
|
608
|
+
catch(e){if(isWrite){exec_log.error('Backup failed — aborting',{error:e.message});return{ok:false,stage:'backup',error:'BACKUP_FAILED',message:e.message}};exec_log.warn('Backup failed (non-fatal)',{error:e.message})}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Execute
|
|
612
|
+
exec_log.info('Executing adapter',{adapter,target});
|
|
613
|
+
let adResult;
|
|
614
|
+
try{adResult=await runAdapter(adapter,cmd,policy,rp)}
|
|
615
|
+
catch(e){adResult={adapter,commandId,status:'error',error:e.message,exitCode:1}}
|
|
616
|
+
exec_log.debug('Adapter returned',{status:adResult.status,exitCode:adResult.exitCode});
|
|
617
|
+
|
|
618
|
+
// Post-execution validation
|
|
619
|
+
let failed=adResult.status==='error'||(adResult.exitCode!==0&&adResult.exitCode!==undefined);
|
|
620
|
+
let postCheck=null;
|
|
621
|
+
if(tx.validate&&target&&!failed){
|
|
622
|
+
let writeActions=['write','patch'];
|
|
623
|
+
if(writeActions.includes(body.payload.action)){
|
|
624
|
+
let exists=fs.existsSync(path.resolve(target));
|
|
625
|
+
postCheck={ok:exists,check:'target_exists',target};
|
|
626
|
+
if(!exists){exec_log.error('Post-exec validation failed',{target});adResult.status='error'}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Rollback — WASM decides whether to rollback
|
|
631
|
+
let rollbackResult=null;
|
|
632
|
+
if((failed||(postCheck&&!postCheck.ok))&&tx.rollbackOnFailure&&bk){
|
|
633
|
+
let doRollback=shouldRollback({execFailed:failed,postCheckFailed:postCheck&&!postCheck.ok,backupExists:!!bk.backupPath,rollbackEnabled:true});
|
|
634
|
+
if(doRollback)try{rollbackResult=restoreBackup(bk);exec_log.warn('Rollback executed',rollbackResult)}catch(e){rollbackResult={restored:false,error:e.message};exec_log.error('Rollback failed',{error:e.message})}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if(tx.audit)appendAuditEntry(paths.auditLog,{commandId,status:rollbackResult?.restored?'rolled_back':adResult.status||'completed',requesterId:actor,payloadHash:payloadHash(cmd),executionHash:payloadHash(adResult),adapter,intent,riskLevel:risk,exitCode:adResult.exitCode||0,rolledBack:rollbackResult?.restored||false});
|
|
638
|
+
|
|
639
|
+
let ok=!failed&&(!postCheck||postCheck.ok);
|
|
640
|
+
exec_log.info('execute() complete',{ok,risk});
|
|
641
|
+
return{ok,commandId,intent,actor,target,risk,stage:ok?'executed':'failed',status:adResult.status,output:adResult.output||null,exitCode:adResult.exitCode??0,checks:val.checks,backup:bk?{path:bk.backupPath,existed:bk.existed,hash:bk.hash}:null,rollback:rollbackResult,postValidation:postCheck,operationLog:logLevel==='DEBUG'?log.exportLogs():undefined}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return{
|
|
645
|
+
execute,
|
|
646
|
+
exportLogs:()=>log.exportLogs(),
|
|
647
|
+
async writeFile(target,content){
|
|
648
|
+
let r=await execute({actor:defaultActor,intent:'write_file',target,content,transaction:{backup:true,rollbackOnFailure:true,audit:true}});
|
|
649
|
+
if(!r.ok){let e=new Error(`LBE write failed [${r.error||r.stage}]${r.message?': '+r.message:''}`);e.lbeResult=r;throw e}return r
|
|
650
|
+
},
|
|
651
|
+
async readFile(target){
|
|
652
|
+
let r=await execute({actor:defaultActor,intent:'read_file',target,transaction:{audit:true}});
|
|
653
|
+
if(!r.ok){let e=new Error(`LBE read failed [${r.error||r.stage}]${r.message?': '+r.message:''}`);e.lbeResult=r;throw e}return r.output
|
|
654
|
+
},
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── sandbox ───────────────────────────────────────────────────────────────────
|
|
659
|
+
export function sandbox(rootDir,options={}){
|
|
660
|
+
let{audit=false,rollback=false}=options;
|
|
661
|
+
let rp=path.resolve(rootDir);
|
|
662
|
+
let lbe=createLBE({rootDir:rp,state:options.state||'local',logSilent:true});
|
|
663
|
+
let abs=p=>path.isAbsolute(p)?p:path.join(rp,p);
|
|
664
|
+
let tx={backup:rollback,rollbackOnFailure:rollback,audit};
|
|
665
|
+
function blocked(method,r){let e=new Error(`sandbox.${method} blocked [${r.error}]${r.message?': '+r.message:''}`);e.lbeResult=r;return e}
|
|
666
|
+
return{
|
|
667
|
+
async write(p,content){let r=await lbe.execute({actor:'agent:sdk',intent:'write_file',target:abs(p),content,transaction:tx});if(!r.ok)throw blocked('write',r)},
|
|
668
|
+
async read(p){let r=await lbe.execute({actor:'agent:sdk',intent:'read_file',target:abs(p),transaction:{audit}});if(!r.ok)throw blocked('read',r);return r.output},
|
|
669
|
+
async patch(p,content){let r=await lbe.execute({actor:'agent:sdk',intent:'patch_file',target:abs(p),content,transaction:tx});if(!r.ok)throw blocked('patch',r)},
|
|
670
|
+
lbe,
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── makeKeyStore convenience helper ──────────────────────────────────────────
|
|
675
|
+
function makeKeyStore({publicKey,keyId,validDays=365}){
|
|
676
|
+
let now=new Date(),exp=new Date(now.getTime()+validDays*24*3600*1e3);
|
|
677
|
+
return{defaultKeyId:keyId,trustedKeys:{[keyId]:{publicKey,notBefore:now.toISOString(),expiresAt:exp.toISOString()}}}
|
|
678
|
+
}
|