@volter/teams 0.2.4 → 0.2.6
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/board-process-guard.mjs +52 -0
- package/event-limits.mjs +3 -0
- package/package.json +3 -1
- package/server/http.mjs +1 -1
- package/server/published-events.mjs +2 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The board's native PreToolUse command policy. No process lookup or signal occurs here.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
export const processGuardReason = 'Pattern kills are refused for board sessions. Kill only the PID this session recorded when it started that process; use the owning service door for a service.';
|
|
7
|
+
export function patternKill(command) {
|
|
8
|
+
// Inspect shell bodies as well as nested `sh -c` / substitutions. Removing shell
|
|
9
|
+
// escapes and quotes catches split words, paths and quoted embedded commands.
|
|
10
|
+
const text = String(command).replace(/\\\r?\n/g, '').replace(/\\([a-zA-Z-])/g, '$1').replace(/["']/g, '');
|
|
11
|
+
const head = String.raw`(?:^|[;|&(\n{}]|\s-[a-zA-Z]*c\s+)\s*(?:(?:env|sudo|exec|command|xargs|nohup|do|then|else|!)\s+(?:--?[^\s;|&()]+\s+)*|[A-Za-z_][A-Za-z0-9_]*=[^\s;|&()]+\s+)*(?:[\w./-]+/)?`;
|
|
12
|
+
return new RegExp(head + String.raw`(?:pkill|killall)(?=$|[\s;|&()])`).test(text) ||
|
|
13
|
+
new RegExp(head + String.raw`pgrep\s+[^;|&\n)]*(?:--full\b|-[a-zA-Z]*f[a-zA-Z]*(?=$|\s))`).test(text);
|
|
14
|
+
}
|
|
15
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
16
|
+
export function boardGuardArguments(kind, args, { cwd, enabled, node = process.execPath } = {}) {
|
|
17
|
+
if (!enabled) return args;
|
|
18
|
+
const command = `${quote(node)} ${quote(fileURLToPath(import.meta.url))}`;
|
|
19
|
+
const hooks = [{ matcher: '^Bash$', hooks: [{ type: 'command', command, timeout: 10 }] }];
|
|
20
|
+
if (kind === 'claude-code') {
|
|
21
|
+
const next = [...args], at = next.findIndex(arg => arg === '--settings' || arg.startsWith('--settings='));
|
|
22
|
+
if (at < 0) return [...next, '--settings', JSON.stringify({ hooks: { PreToolUse: hooks } })];
|
|
23
|
+
const inline = next[at].startsWith('--settings='), value = inline ? next[at].slice(11) : next[at + 1];
|
|
24
|
+
const settings = JSON.parse(value?.trimStart().startsWith('{') ? value : readFileSync(resolve(cwd || process.cwd(), value), 'utf8'));
|
|
25
|
+
settings.hooks ??= {}; settings.hooks.PreToolUse = [...(settings.hooks.PreToolUse ?? []), ...hooks];
|
|
26
|
+
next.splice(at, inline ? 1 : 2, '--settings', JSON.stringify(settings));
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
if (kind === 'codex') {
|
|
30
|
+
// Native Codex rules cannot split substitutions such as kill $(pgrep -f …).
|
|
31
|
+
// Its synchronous Bash hook can deny the entire command before execution.
|
|
32
|
+
// This invocation enables and trusts the board-vetted command policy.
|
|
33
|
+
const handler = `{ matcher = "^Bash$", hooks = [{ type = "command", command = ${JSON.stringify(command)}, timeout = 10 }] }`;
|
|
34
|
+
// Codex's own flags go before any subcommand (`app-server`, `resume`) or prompt: after `app-server`, Codex
|
|
35
|
+
// 0.159 refuses --dangerously-bypass-hook-trust and the worker never starts.
|
|
36
|
+
return [...(args.includes('--dangerously-bypass-hook-trust') ? [] : ['--dangerously-bypass-hook-trust']), '-c', 'features.hooks=true', '-c', `hooks.PreToolUse=[${handler}]`, ...args];
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`board process guard is unavailable for ${kind}; no unguarded session was launched`);
|
|
39
|
+
}
|
|
40
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
41
|
+
try {
|
|
42
|
+
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
43
|
+
const command = input.tool_input?.command ?? input.tool_input?.cmd;
|
|
44
|
+
if (typeof command !== 'string') throw new Error('Bash hook input has no command');
|
|
45
|
+
if (patternKill(command)) process.stdout.write(JSON.stringify({ hookSpecificOutput: {
|
|
46
|
+
hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: processGuardReason,
|
|
47
|
+
} }) + '\n');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
process.stderr.write(`Board process policy could not read the command: ${error.message}. ${processGuardReason}\n`);
|
|
50
|
+
process.exitCode = 2;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/event-limits.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,8 +45,10 @@
|
|
|
45
45
|
},
|
|
46
46
|
"./context": "./context.mjs",
|
|
47
47
|
"./event-client": "./event-client.mjs",
|
|
48
|
+
"./event-limits": "./event-limits.mjs",
|
|
48
49
|
"./machine-door": "./machine-door.mjs",
|
|
49
50
|
"./sender-identity": "./sender-identity.mjs",
|
|
51
|
+
"./board-process-guard": "./board-process-guard.mjs",
|
|
50
52
|
"./server/agent-authority": "./server/agent-authority.mjs"
|
|
51
53
|
},
|
|
52
54
|
"files": [
|
package/server/http.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const parseJson=req=>parseRequestJson(req,fail);
|
|
|
23
23
|
const result=(api,auth,data)=>({version:1,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},data});
|
|
24
24
|
function sideEffectStatements(api,res,status,body,now,targetId=null,journal=false){const statements=[];if(status<400&&res._idempotencyRecord){const record=res._idempotencyRecord,safe=redactSecretReplay(record.path,body);statements.push({sql:'INSERT INTO idempotency(team_id,principal_id,route,key,request_digest,response_status,response_json,expires_at) VALUES (?,?,?,?,?,?,?,?)',params:[record.teamId,record.principalId,record.route,record.key,record.requestDigest,status,journal?{$journalJsonValue:safe}:JSON.stringify(safe),now+86400000]});}if(res._auditRecord){const record=res._auditRecord;statements.push({sql:'INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,revision,created_at,actor_json) VALUES (?,?,?,?,?,?,?,?,?)',params:[record.teamId,api.ids.next('audit'),record.actorId,record.operation,targetId,status<400?'allowed':'denied',journal?{$journalRevision:true}:null,now,JSON.stringify(record.act??[])]});}return statements;}
|
|
25
25
|
async function persistSideEffects(api,res,status,body){const statements=sideEffectStatements(api,res,status,body,api.clock.nowMs());if(statements.length)await api.store.transaction(statements);}
|
|
26
|
-
function send(res,status,body,requestId,headers={}){const deliver=()=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Request-ID':requestId,...headers});res.end(body===null?'':JSON.stringify(body));};if(!res._sideEffectsCommitted&&res._api&&(res._idempotencyRecord||res._auditRecord))return persistSideEffects(res._api,res,status,body).then(deliver);deliver();}
|
|
26
|
+
function send(res,status,body,requestId,headers={}){const deliver=()=>{res.writeHead(status,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Request-ID':requestId,...headers});res.end(body===null?'':JSON.stringify(body));};if(!res._sideEffectsCommitted&&res._api&&(res._idempotencyRecord||res._auditRecord))return persistSideEffects(res._api,res,status,body).then(deliver,error=>{if(/UNIQUE constraint failed: idempotency\./.test(error?.message??''))return deliver();if(res.headersSent)return;res.writeHead(500,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store','X-Request-ID':requestId});res.end(JSON.stringify({error:{code:'internal',message:'The request was not recorded.'}}));});deliver();}
|
|
27
27
|
async function guardedMutation(api,res,auth,guards,statements,{status,body,targetId=null,journal=false}){const now=api.clock.nowMs(),all=[...statements,...sideEffectStatements(api,res,status,body,now,targetId,journal)],result=journal?await api.store.journalTransaction(auth.team_id,[...guards,...appAuthGuards(auth,now)],all):await api.store.guardedTransaction([...guards,...appAuthGuards(auth,now)],all);res._sideEffectsCommitted=true;api.events.publish(auth.team_id,{type:'events_available'});return result;}
|
|
28
28
|
const requireRevision=(req,current,label='resource')=>{const expected=Number(String(req.headers['if-match']??'').replaceAll('"',''));if(!Number.isSafeInteger(expected))fail(400,'invalid_request','If-Match is required');if(expected!==current)fail(409,'revision_mismatch',`${label} revision changed`);return expected;};
|
|
29
29
|
const CORS={'Access-Control-Allow-Origin':'*','Access-Control-Expose-Headers':'ETag, X-Request-ID, Content-Range'};
|
|
@@ -5,6 +5,7 @@ import { exact } from './request-json.mjs';
|
|
|
5
5
|
import { canonicalJson } from './wire.mjs';
|
|
6
6
|
import { writeSse } from './events.mjs';
|
|
7
7
|
import { APP_EVENTS, PUBLISHABLE_EVENTS, eventReadAction } from './event-catalogue.mjs';
|
|
8
|
+
import { EVENT_DATA_LIMIT } from '../event-limits.mjs';
|
|
8
9
|
|
|
9
10
|
const stmt = (sql, ...params) => ({sql, params});
|
|
10
11
|
const fail = (status,code,message) => {throw Object.assign(new Error(message),{status,code});};
|
|
@@ -69,7 +70,7 @@ export async function publishedEventRoute(ctx) {
|
|
|
69
70
|
if(publication&&req.method==='POST'){
|
|
70
71
|
const body=await parseJson(req);exact(body,['id','kind','resource','revision','data','actor']);
|
|
71
72
|
text(body.id,'id');text(body.resource?.id,'resource.id');exact(body.resource,['kind','id']);
|
|
72
|
-
if(!PUBLISHABLE_EVENTS.includes(body.kind)||body.resource.kind!==body.kind.split('.')[0]||!body.resource.id.startsWith(`${publication[1]}:`)||!Number.isSafeInteger(body.revision)||body.revision<1||!body.data||typeof body.data!=='object'||Array.isArray(body.data)||Buffer.byteLength(JSON.stringify(body.data))>
|
|
73
|
+
if(!PUBLISHABLE_EVENTS.includes(body.kind)||body.resource.kind!==body.kind.split('.')[0]||!body.resource.id.startsWith(`${publication[1]}:`)||!Number.isSafeInteger(body.revision)||body.revision<1||!body.data||typeof body.data!=='object'||Array.isArray(body.data)||Buffer.byteLength(JSON.stringify(body.data))>EVENT_DATA_LIMIT)fail(400,'invalid_request','invalid source event or snapshot');
|
|
73
74
|
// A source can prove only its own principal. A plain session is evidence, never principal identity.
|
|
74
75
|
const actor={principal:auth.principal_id,session:null,lineage:auth.act??[]};
|
|
75
76
|
if(body.actor!=null){exact(body.actor,['session']);if(body.actor.session!=null){text(body.actor.session,'actor.session');if(!body.actor.session.startsWith('session:'))fail(400,'invalid_request','actor.session must name a session');actor.session=body.actor.session;}}
|