feat: add claw approval MVP with privileged broker
Some checks failed
Stale / stale (push) Has been cancelled
Stale / lock-closed-issues (push) Has been cancelled

Implement Postgres-backed claw approval flow and integrate gateway methods for create/list/get/approve/reject/execute/audit. Add a minimal systemd-run privileged broker with bearer auth, strict scope and exact-command validation, dangerous-shell blocking, atomic once-grant consumption, and execution audit updates.
This commit is contained in:
Fedor
2026-03-13 12:41:23 +00:00
parent 70d7a0854c
commit 2cbe4e2808
11 changed files with 1666 additions and 247 deletions

View File

@@ -382,6 +382,7 @@
"opusscript": "^0.1.1", "opusscript": "^0.1.1",
"osc-progress": "^0.3.0", "osc-progress": "^0.3.0",
"pdfjs-dist": "^5.5.207", "pdfjs-dist": "^5.5.207",
"pg": "^8.20.0",
"playwright-core": "1.58.2", "playwright-core": "1.58.2",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
@@ -400,6 +401,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/pg": "^8.18.0",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260312.1", "@typescript/native-preview": "7.0.0-dev.20260312.1",

365
pnpm-lock.yaml generated
View File

@@ -163,6 +163,9 @@ importers:
pdfjs-dist: pdfjs-dist:
specifier: ^5.5.207 specifier: ^5.5.207
version: 5.5.207 version: 5.5.207
pg:
specifier: ^8.20.0
version: 8.20.0
playwright-core: playwright-core:
specifier: 1.58.2 specifier: 1.58.2
version: 1.58.2 version: 1.58.2
@@ -212,6 +215,9 @@ importers:
'@types/node': '@types/node':
specifier: ^25.5.0 specifier: ^25.5.0
version: 25.5.0 version: 25.5.0
'@types/pg':
specifier: ^8.18.0
version: 8.18.0
'@types/qrcode-terminal': '@types/qrcode-terminal':
specifier: ^0.12.2 specifier: ^0.12.2
version: 0.12.2 version: 0.12.2
@@ -651,10 +657,6 @@ packages:
resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==} resolution: {integrity: sha512-t8cl+bPLlHZQD2Sw1a4hSLUybqJZU71+m8znkyeU8CHntFqEp2mMbuLKdHKaAYQ1fAApXMsvzenCAkDzNeeJlw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock@3.1007.0':
resolution: {integrity: sha512-49hH8o6ALKkCiBUgg20HkwxNamP1yYA/n8Si73Z438EqhZGpCfScP3FfxVhrfD5o+4bV4Whi9BTzPKCa/PfUww==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-bedrock@3.1008.0': '@aws-sdk/client-bedrock@3.1008.0':
resolution: {integrity: sha512-mzxO/DplpZZT7AIZUCG7Q78OlaeHeDybYz+ZlWZPaXFjGDJwUv1E3SKskmaaQvTsMeieie0WX7gzueYrCx4YfQ==} resolution: {integrity: sha512-mzxO/DplpZZT7AIZUCG7Q78OlaeHeDybYz+ZlWZPaXFjGDJwUv1E3SKskmaaQvTsMeieie0WX7gzueYrCx4YfQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -711,10 +713,6 @@ packages:
resolution: {integrity: sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==} resolution: {integrity: sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.18':
resolution: {integrity: sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.19': '@aws-sdk/credential-provider-ini@3.972.19':
resolution: {integrity: sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==} resolution: {integrity: sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -727,10 +725,6 @@ packages:
resolution: {integrity: sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==} resolution: {integrity: sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.18':
resolution: {integrity: sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.19': '@aws-sdk/credential-provider-login@3.972.19':
resolution: {integrity: sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==} resolution: {integrity: sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -743,10 +737,6 @@ packages:
resolution: {integrity: sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==} resolution: {integrity: sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.19':
resolution: {integrity: sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.20': '@aws-sdk/credential-provider-node@3.972.20':
resolution: {integrity: sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==} resolution: {integrity: sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -771,10 +761,6 @@ packages:
resolution: {integrity: sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==} resolution: {integrity: sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.18':
resolution: {integrity: sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.19': '@aws-sdk/credential-provider-sso@3.972.19':
resolution: {integrity: sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==} resolution: {integrity: sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -787,10 +773,6 @@ packages:
resolution: {integrity: sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==} resolution: {integrity: sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.18':
resolution: {integrity: sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.19': '@aws-sdk/credential-provider-web-identity@3.972.19':
resolution: {integrity: sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==} resolution: {integrity: sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -875,10 +857,6 @@ packages:
resolution: {integrity: sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==} resolution: {integrity: sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.996.8':
resolution: {integrity: sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.996.9': '@aws-sdk/nested-clients@3.996.9':
resolution: {integrity: sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==} resolution: {integrity: sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -903,14 +881,6 @@ packages:
resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==} resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1005.0':
resolution: {integrity: sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1007.0':
resolution: {integrity: sha512-kKvVyr53vvVc5k6RbvI6jhafxufxO2SkEw8QeEzJqwOXH/IMY7Cm0IyhnBGdqj80iiIIiIM2jGe7Fn3TIdwdrw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1008.0': '@aws-sdk/token-providers@3.1008.0':
resolution: {integrity: sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==} resolution: {integrity: sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -979,15 +949,6 @@ packages:
aws-crt: aws-crt:
optional: true optional: true
'@aws-sdk/util-user-agent-node@3.973.5':
resolution: {integrity: sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==}
engines: {node: '>=20.0.0'}
peerDependencies:
aws-crt: '>=1.0.0'
peerDependenciesMeta:
aws-crt:
optional: true
'@aws-sdk/util-user-agent-node@3.973.6': '@aws-sdk/util-user-agent-node@3.973.6':
resolution: {integrity: sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==} resolution: {integrity: sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -3586,6 +3547,9 @@ packages:
'@types/node@25.5.0': '@types/node@25.5.0':
resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==}
'@types/pg@8.18.0':
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
'@types/qrcode-terminal@0.12.2': '@types/qrcode-terminal@0.12.2':
resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==}
@@ -5845,6 +5809,40 @@ packages:
performance-now@2.1.0: performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5892,6 +5890,22 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres@3.4.8: postgres@3.4.8:
resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -6667,10 +6681,6 @@ packages:
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
undici@7.24.0: undici@7.24.0:
resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==} resolution: {integrity: sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
@@ -6928,6 +6938,10 @@ packages:
xmlchars@2.2.0: xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -7120,51 +7134,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/client-bedrock@3.1007.0':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.19
'@aws-sdk/credential-provider-node': 3.972.19
'@aws-sdk/middleware-host-header': 3.972.7
'@aws-sdk/middleware-logger': 3.972.7
'@aws-sdk/middleware-recursion-detection': 3.972.7
'@aws-sdk/middleware-user-agent': 3.972.20
'@aws-sdk/region-config-resolver': 3.972.7
'@aws-sdk/token-providers': 3.1007.0
'@aws-sdk/types': 3.973.5
'@aws-sdk/util-endpoints': 3.996.4
'@aws-sdk/util-user-agent-browser': 3.972.7
'@aws-sdk/util-user-agent-node': 3.973.5
'@smithy/config-resolver': 4.4.10
'@smithy/core': 3.23.9
'@smithy/fetch-http-handler': 5.3.13
'@smithy/hash-node': 4.2.11
'@smithy/invalid-dependency': 4.2.11
'@smithy/middleware-content-length': 4.2.11
'@smithy/middleware-endpoint': 4.4.23
'@smithy/middleware-retry': 4.4.40
'@smithy/middleware-serde': 4.2.12
'@smithy/middleware-stack': 4.2.11
'@smithy/node-config-provider': 4.3.11
'@smithy/node-http-handler': 4.4.14
'@smithy/protocol-http': 5.3.11
'@smithy/smithy-client': 4.12.3
'@smithy/types': 4.13.0
'@smithy/url-parser': 4.2.11
'@smithy/util-base64': 4.3.2
'@smithy/util-body-length-browser': 4.2.2
'@smithy/util-body-length-node': 4.2.3
'@smithy/util-defaults-mode-browser': 4.3.39
'@smithy/util-defaults-mode-node': 4.2.42
'@smithy/util-endpoints': 3.3.2
'@smithy/util-middleware': 4.2.11
'@smithy/util-retry': 4.2.11
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/client-bedrock@3.1008.0': '@aws-sdk/client-bedrock@3.1008.0':
dependencies: dependencies:
'@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0
@@ -7424,25 +7393,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/credential-provider-ini@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/credential-provider-env': 3.972.17
'@aws-sdk/credential-provider-http': 3.972.19
'@aws-sdk/credential-provider-login': 3.972.18
'@aws-sdk/credential-provider-process': 3.972.17
'@aws-sdk/credential-provider-sso': 3.972.18
'@aws-sdk/credential-provider-web-identity': 3.972.18
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/credential-provider-imds': 4.2.11
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-ini@3.972.19': '@aws-sdk/credential-provider-ini@3.972.19':
dependencies: dependencies:
'@aws-sdk/core': 3.973.19 '@aws-sdk/core': 3.973.19
@@ -7488,19 +7438,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/credential-provider-login@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/protocol-http': 5.3.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-login@3.972.19': '@aws-sdk/credential-provider-login@3.972.19':
dependencies: dependencies:
'@aws-sdk/core': 3.973.19 '@aws-sdk/core': 3.973.19
@@ -7548,23 +7485,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/credential-provider-node@3.972.19':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.17
'@aws-sdk/credential-provider-http': 3.972.19
'@aws-sdk/credential-provider-ini': 3.972.18
'@aws-sdk/credential-provider-process': 3.972.17
'@aws-sdk/credential-provider-sso': 3.972.18
'@aws-sdk/credential-provider-web-identity': 3.972.18
'@aws-sdk/types': 3.973.5
'@smithy/credential-provider-imds': 4.2.11
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-node@3.972.20': '@aws-sdk/credential-provider-node@3.972.20':
dependencies: dependencies:
'@aws-sdk/credential-provider-env': 3.972.17 '@aws-sdk/credential-provider-env': 3.972.17
@@ -7635,19 +7555,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/credential-provider-sso@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/token-providers': 3.1005.0
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-sso@3.972.19': '@aws-sdk/credential-provider-sso@3.972.19':
dependencies: dependencies:
'@aws-sdk/core': 3.973.19 '@aws-sdk/core': 3.973.19
@@ -7685,18 +7592,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/credential-provider-web-identity@3.972.18':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-web-identity@3.972.19': '@aws-sdk/credential-provider-web-identity@3.972.19':
dependencies: dependencies:
'@aws-sdk/core': 3.973.19 '@aws-sdk/core': 3.973.19
@@ -7961,49 +7856,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/nested-clients@3.996.8':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.973.19
'@aws-sdk/middleware-host-header': 3.972.7
'@aws-sdk/middleware-logger': 3.972.7
'@aws-sdk/middleware-recursion-detection': 3.972.7
'@aws-sdk/middleware-user-agent': 3.972.20
'@aws-sdk/region-config-resolver': 3.972.7
'@aws-sdk/types': 3.973.5
'@aws-sdk/util-endpoints': 3.996.4
'@aws-sdk/util-user-agent-browser': 3.972.7
'@aws-sdk/util-user-agent-node': 3.973.5
'@smithy/config-resolver': 4.4.10
'@smithy/core': 3.23.9
'@smithy/fetch-http-handler': 5.3.13
'@smithy/hash-node': 4.2.11
'@smithy/invalid-dependency': 4.2.11
'@smithy/middleware-content-length': 4.2.11
'@smithy/middleware-endpoint': 4.4.23
'@smithy/middleware-retry': 4.4.40
'@smithy/middleware-serde': 4.2.12
'@smithy/middleware-stack': 4.2.11
'@smithy/node-config-provider': 4.3.11
'@smithy/node-http-handler': 4.4.14
'@smithy/protocol-http': 5.3.11
'@smithy/smithy-client': 4.12.3
'@smithy/types': 4.13.0
'@smithy/url-parser': 4.2.11
'@smithy/util-base64': 4.3.2
'@smithy/util-body-length-browser': 4.2.2
'@smithy/util-body-length-node': 4.2.3
'@smithy/util-defaults-mode-browser': 4.3.39
'@smithy/util-defaults-mode-node': 4.2.42
'@smithy/util-endpoints': 3.3.2
'@smithy/util-middleware': 4.2.11
'@smithy/util-retry': 4.2.11
'@smithy/util-utf8': 4.2.2
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/nested-clients@3.996.9': '@aws-sdk/nested-clients@3.996.9':
dependencies: dependencies:
'@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0
@@ -8095,30 +7947,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
'@aws-sdk/token-providers@3.1005.0':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.1007.0':
dependencies:
'@aws-sdk/core': 3.973.19
'@aws-sdk/nested-clients': 3.996.8
'@aws-sdk/types': 3.973.5
'@smithy/property-provider': 4.2.11
'@smithy/shared-ini-file-loader': 4.4.6
'@smithy/types': 4.13.0
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/token-providers@3.1008.0': '@aws-sdk/token-providers@3.1008.0':
dependencies: dependencies:
'@aws-sdk/core': 3.973.19 '@aws-sdk/core': 3.973.19
@@ -8225,14 +8053,6 @@ snapshots:
'@smithy/types': 4.13.0 '@smithy/types': 4.13.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.973.5':
dependencies:
'@aws-sdk/middleware-user-agent': 3.972.20
'@aws-sdk/types': 3.973.5
'@smithy/node-config-provider': 4.3.11
'@smithy/types': 4.13.0
tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.973.6': '@aws-sdk/util-user-agent-node@3.973.6':
dependencies: dependencies:
'@aws-sdk/middleware-user-agent': 3.972.20 '@aws-sdk/middleware-user-agent': 3.972.20
@@ -11167,6 +10987,12 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/pg@8.18.0':
dependencies:
'@types/node': 25.5.0
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/qrcode-terminal@0.12.2': {} '@types/qrcode-terminal@0.12.2': {}
'@types/qs@6.14.0': {} '@types/qs@6.14.0': {}
@@ -13500,7 +13326,7 @@ snapshots:
openclaw@2026.3.11(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)): openclaw@2026.3.11(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)):
dependencies: dependencies:
'@agentclientprotocol/sdk': 0.16.1(zod@4.3.6) '@agentclientprotocol/sdk': 0.16.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.1007.0 '@aws-sdk/client-bedrock': 3.1008.0
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1) '@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.7)(opusscript@0.1.1)
'@clack/prompts': 1.1.0 '@clack/prompts': 1.1.0
'@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1) '@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1)
@@ -13551,7 +13377,7 @@ snapshots:
sqlite-vec: 0.1.7-alpha.2 sqlite-vec: 0.1.7-alpha.2
tar: 7.5.11 tar: 7.5.11
tslog: 4.10.2 tslog: 4.10.2
undici: 7.22.0 undici: 7.24.0
ws: 8.19.0 ws: 8.19.0
yaml: 2.8.2 yaml: 2.8.2
zod: 4.3.6 zod: 4.3.6
@@ -13756,6 +13582,41 @@ snapshots:
performance-now@2.1.0: {} performance-now@2.1.0: {}
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {}
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
pg-protocol@1.13.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
pg-pool: 3.13.0(pg@8.20.0)
pg-protocol: 1.13.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
@@ -13806,6 +13667,16 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres@3.4.8: {} postgres@3.4.8: {}
pretty-bytes@6.1.1: {} pretty-bytes@6.1.1: {}
@@ -14725,8 +14596,6 @@ snapshots:
undici-types@7.18.2: {} undici-types@7.18.2: {}
undici@7.22.0: {}
undici@7.24.0: {} undici@7.24.0: {}
unist-util-is@6.0.1: unist-util-is@6.0.1:
@@ -14925,6 +14794,8 @@ snapshots:
xmlchars@2.2.0: {} xmlchars@2.2.0: {}
xtend@4.0.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@4.0.0: {} yallist@4.0.0: {}

View File

@@ -0,0 +1,11 @@
CLAW_BROKER_BIND=127.0.0.1
CLAW_BROKER_PORT=8787
CLAW_BROKER_TOKEN=change-me
CLAW_BROKER_CMD_TIMEOUT_MS=120000
CLAW_BROKER_MAX_SUMMARY_CHARS=2000
PGHOST=147.45.189.234
PGPORT=5432
PGDATABASE=default_db
PGUSER=gen_user
PGPASSWORD=change-me

View File

@@ -0,0 +1,43 @@
# Claw Broker (MVP)
Minimal privileged broker for claw.approvals.execute.
## API
- POST /v1/execute
- Bearer token via CLAW_BROKER_TOKEN
Request fields:
- executionId
- approvalRequestId
- approvalGrantId
- exactCommand
- targetHost
- targetUser
- requestedBy
- channel
- chatId
- humanUserId
- sessionId
Response fields:
- executionId
- status
- exitCode
- stdoutSummary
- stderrSummary
- startedAt
- finishedAt
## Validation
Broker re-checks in Postgres before execution:
- request/grant exist
- status allows execution
- once grant atomic consume
- command exact match
- scope match (targetHost, targetUser, channel, chatId, humanUserId, sessionId)
- dangerous shell policy

View File

@@ -0,0 +1,437 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import http from "node:http";
import pg from "pg";
const { Pool } = pg;
const MAX_SUMMARY_CHARS = Number(process.env.CLAW_BROKER_MAX_SUMMARY_CHARS ?? "2000");
const CMD_TIMEOUT_MS = Number(process.env.CLAW_BROKER_CMD_TIMEOUT_MS ?? "120000");
const BIND_HOST = process.env.CLAW_BROKER_BIND ?? "127.0.0.1";
const BIND_PORT = Number(process.env.CLAW_BROKER_PORT ?? "8787");
const REQUIRED_TOKEN = (process.env.CLAW_BROKER_TOKEN ?? "").trim();
function env(name, fallback = undefined) {
return process.env[`CLAW_${name}`] ?? process.env[name] ?? fallback;
}
function requiredEnv(name) {
const value = env(name, "");
if (!value || !String(value).trim()) {
throw new Error(`missing env: ${name} (or CLAW_${name})`);
}
return String(value);
}
const pool = new Pool({
host: requiredEnv("PGHOST"),
port: Number(env("PGPORT", "5432")),
user: requiredEnv("PGUSER"),
password: requiredEnv("PGPASSWORD"),
database: requiredEnv("PGDATABASE"),
max: 10,
});
if (!REQUIRED_TOKEN) {
throw new Error("missing CLAW_BROKER_TOKEN");
}
function json(res, code, body) {
const payload = JSON.stringify(body);
res.writeHead(code, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
});
res.end(payload);
}
function normalizeCommand(input) {
return String(input).trim().replace(/\s+/g, " ");
}
function hasDangerousShellConstruct(command) {
const source = String(command).toLowerCase();
const checks = [
/\bbash\s+-c\b/,
/\bsh\s+-c\b/,
/\bsudo\s+su\b/,
/\bsudo\s+-i\b/,
/&&/,
/\|\|/,
/;/,
/\|/,
/>/,
/</,
/\$\(/,
/`/,
/<<[-\w]*/,
];
return checks.some((r) => r.test(source));
}
function summarize(text) {
const value = String(text ?? "");
return value.length <= MAX_SUMMARY_CHARS ? value : `${value.slice(0, MAX_SUMMARY_CHARS)}`;
}
async function insertAudit(client, args) {
await client.query(
`INSERT INTO claw_audit_events (
event_type, request_id, grant_id, execution_id,
actor_type, actor_id, target_host, target_user,
command_snapshot, status, exit_code, stdout_summary, stderr_summary, metadata
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb
)`,
[
args.eventType,
args.requestId ?? null,
args.grantId ?? null,
args.executionId ?? null,
args.actorType,
args.actorId,
args.targetHost ?? null,
args.targetUser ?? null,
args.commandSnapshot ?? null,
args.status ?? null,
args.exitCode ?? null,
args.stdoutSummary ?? null,
args.stderrSummary ?? null,
JSON.stringify(args.metadata ?? {}),
],
);
}
function requireString(body, key) {
const value = body?.[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${key} is required`);
}
return value.trim();
}
async function verifyAndMarkStarted(body) {
const executionId = requireString(body, "executionId");
const approvalRequestId = requireString(body, "approvalRequestId");
const approvalGrantId = requireString(body, "approvalGrantId");
const exactCommand = requireString(body, "exactCommand");
const targetHost = requireString(body, "targetHost");
const targetUser = requireString(body, "targetUser");
const requestedBy = requireString(body, "requestedBy");
const channel = requireString(body, "channel");
const chatId = requireString(body, "chatId");
const humanUserId = requireString(body, "humanUserId");
const sessionId = requireString(body, "sessionId");
if (hasDangerousShellConstruct(exactCommand)) {
throw new Error("dangerous shell policy violation");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[approvalRequestId],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const request = reqRes.rows[0];
if (!["approved_once", "approved_always"].includes(String(request.status))) {
throw new Error(`request status does not allow execution: ${request.status}`);
}
const grantRes = await client.query(
`SELECT * FROM claw_approval_grants WHERE id = $1 AND request_id = $2 FOR UPDATE`,
[approvalGrantId, approvalRequestId],
);
if (grantRes.rowCount === 0) {
throw new Error("approval grant not found");
}
const grant = grantRes.rows[0];
const dbExact = String(request.exact_command);
if (normalizeCommand(dbExact) !== normalizeCommand(exactCommand)) {
throw new Error("exact command mismatch");
}
if (normalizeCommand(String(grant.exact_command)) !== normalizeCommand(exactCommand)) {
throw new Error("grant command mismatch");
}
const scopeChecks = [
[String(request.target_host), targetHost, "targetHost"],
[String(request.target_user), targetUser, "targetUser"],
[String(request.channel), channel, "channel"],
[String(request.chat_id), chatId, "chatId"],
[String(request.human_user_id), humanUserId, "humanUserId"],
[String(request.session_id), sessionId, "sessionId"],
[String(grant.target_host), targetHost, "grant.targetHost"],
[String(grant.target_user), targetUser, "grant.targetUser"],
[String(grant.channel), channel, "grant.channel"],
[String(grant.chat_id), chatId, "grant.chatId"],
[String(grant.human_user_id), humanUserId, "grant.humanUserId"],
[String(grant.session_id), sessionId, "grant.sessionId"],
];
for (const [db, incoming, label] of scopeChecks) {
if (db !== incoming) {
throw new Error(`scope mismatch: ${label}`);
}
}
if (hasDangerousShellConstruct(String(request.exact_command))) {
throw new Error("dangerous shell policy violation (request)");
}
if (String(grant.grant_type) === "once") {
const consumeRes = await client.query(
`UPDATE claw_approval_grants
SET used_at = now()
WHERE id = $1
AND grant_type = 'once'
AND used_at IS NULL
AND revoked_at IS NULL
AND expires_at > now()
RETURNING id`,
[approvalGrantId],
);
if (consumeRes.rowCount === 0) {
throw new Error("once grant expired/revoked/already used");
}
await insertAudit(client, {
eventType: "grant_consumed",
actorType: "broker",
actorId: requestedBy,
requestId: approvalRequestId,
grantId: approvalGrantId,
executionId,
targetHost,
targetUser,
commandSnapshot: exactCommand,
status: "grant_consumed",
});
}
await client.query(
`UPDATE claw_approval_requests SET execution_id = $2, updated_at = now() WHERE id = $1`,
[approvalRequestId, executionId],
);
await insertAudit(client, {
eventType: "execution_started",
actorType: "broker",
actorId: requestedBy,
requestId: approvalRequestId,
grantId: approvalGrantId,
executionId,
targetHost,
targetUser,
commandSnapshot: exactCommand,
status: "execution_started",
});
await client.query("COMMIT");
return {
executionId,
approvalRequestId,
approvalGrantId,
exactCommand,
targetHost,
targetUser,
requestedBy,
cwd: request.cwd ? String(request.cwd) : undefined,
};
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async function runCommand(command, cwd) {
return await new Promise((resolve) => {
const child = spawn("bash", ["-lc", command], {
cwd: cwd || undefined,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, CMD_TIMEOUT_MS);
child.stdout.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({
exitCode: timedOut ? 124 : Number(code ?? 1),
stdout,
stderr: timedOut ? `${stderr}\nCommand timed out.` : stderr,
});
});
});
}
async function finalizeExecution({
executionId,
approvalRequestId,
approvalGrantId,
exactCommand,
targetHost,
targetUser,
requestedBy,
ok,
exitCode,
stdoutSummary,
stderrSummary,
}) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const finalStatus = ok ? "executed" : "execution_failed";
const lastError = ok ? null : stderrSummary;
await client.query(
`UPDATE claw_approval_requests
SET status = $2::claw_approval_status,
executed_at = now(),
updated_at = now(),
last_error = $3
WHERE id = $1`,
[approvalRequestId, finalStatus, lastError],
);
await insertAudit(client, {
eventType: ok ? "execution_succeeded" : "execution_failed",
actorType: "broker",
actorId: requestedBy,
requestId: approvalRequestId,
grantId: approvalGrantId,
executionId,
targetHost,
targetUser,
commandSnapshot: exactCommand,
status: ok ? "executed" : "execution_failed",
exitCode,
stdoutSummary,
stderrSummary,
});
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async function handleExecute(body) {
const startedAt = new Date().toISOString();
const validated = await verifyAndMarkStarted(body);
const run = await runCommand(validated.exactCommand, validated.cwd);
const ok = run.exitCode === 0;
const stdoutSummary = summarize(run.stdout);
const stderrSummary = summarize(run.stderr);
await finalizeExecution({
executionId: validated.executionId,
approvalRequestId: validated.approvalRequestId,
approvalGrantId: validated.approvalGrantId,
exactCommand: validated.exactCommand,
targetHost: validated.targetHost,
targetUser: validated.targetUser,
requestedBy: validated.requestedBy,
ok,
exitCode: run.exitCode,
stdoutSummary,
stderrSummary,
});
const finishedAt = new Date().toISOString();
return {
ok,
executionId: validated.executionId,
status: ok ? "executed" : "execution_failed",
exitCode: run.exitCode,
stdoutSummary,
stderrSummary,
startedAt,
finishedAt,
};
}
function getBearerToken(req) {
const raw = String(req.headers.authorization ?? "");
if (!raw.toLowerCase().startsWith("bearer ")) {
return "";
}
return raw.slice(7).trim();
}
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
if (req.method === "GET" && url.pathname === "/healthz") {
json(res, 200, { ok: true, service: "claw-broker" });
return;
}
if (req.method !== "POST" || url.pathname !== "/v1/execute") {
json(res, 404, { ok: false, error: "not_found" });
return;
}
const token = getBearerToken(req);
if (!token || token !== REQUIRED_TOKEN) {
json(res, 401, { ok: false, error: "unauthorized" });
return;
}
let raw = "";
req.on("data", (chunk) => {
raw += chunk.toString("utf8");
if (raw.length > 1_000_000) {
req.destroy();
}
});
req.on("end", async () => {
const fallbackExecutionId = randomUUID();
try {
const body = raw.length ? JSON.parse(raw) : {};
if (!body.executionId) {
body.executionId = fallbackExecutionId;
}
const result = await handleExecute(body);
json(res, 200, result);
} catch (err) {
console.error("[claw-broker] execute error:", err);
const nowIso = new Date().toISOString();
json(res, 400, {
ok: false,
executionId: fallbackExecutionId,
status: "execution_failed",
exitCode: 1,
stdoutSummary: "",
stderrSummary: String(err),
startedAt: nowIso,
finishedAt: nowIso,
});
}
});
});
server.listen(BIND_PORT, BIND_HOST, () => {
process.stdout.write(`claw-broker listening on http://${BIND_HOST}:${BIND_PORT}\n`);
});

View File

@@ -0,0 +1,20 @@
[Unit]
Description=OpenClaw Privileged Broker (MVP)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/negodiy/claw-broker
EnvironmentFile=/home/negodiy/claw-broker/.env
ExecStart=/usr/bin/node /home/negodiy/claw-broker/broker.mjs
Restart=always
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=no
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
{
"name": "claw-broker",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "broker.mjs",
"scripts": {
"start": "node broker.mjs"
},
"dependencies": {
"pg": "^8.20.0"
}
}

View File

@@ -0,0 +1,682 @@
import { randomUUID } from "node:crypto";
import { Pool, type PoolClient } from "pg";
export type ClawApprovalStatus =
| "pending"
| "approved_once"
| "approved_always"
| "rejected"
| "expired"
| "executed"
| "execution_failed";
export type ClawRiskLevel = "low" | "medium" | "high";
export type ClawApprovalRequestRow = {
id: string;
createdAt: string;
updatedAt: string;
requestedByAgent: string;
sessionId: string;
channel: string;
chatId: string;
humanUserId: string;
targetHost: string;
targetUser: string;
cwd: string | null;
humanSummary: string;
reason: string;
exactCommand: string;
normalizedCommand: string;
riskLevel: ClawRiskLevel;
rollbackHint: string | null;
requiresPrivilege: boolean;
dangerousFlags: Record<string, boolean>;
status: ClawApprovalStatus;
statusReason: string | null;
approvedBy: string | null;
approvedAt: string | null;
rejectedBy: string | null;
rejectedAt: string | null;
expiredAt: string | null;
executedAt: string | null;
executionId: string | null;
lastError: string | null;
};
export type CreateClawApprovalRequestInput = {
requestedByAgent: string;
sessionId: string;
channel: string;
chatId: string;
humanUserId: string;
targetHost: string;
targetUser: string;
cwd?: string | null;
humanSummary: string;
reason: string;
exactCommand: string;
riskLevel: ClawRiskLevel;
rollbackHint?: string | null;
dangerousFlags?: Record<string, boolean>;
};
export type ClawApproveInput = {
id: string;
actorId: string;
};
export type ClawApproveOnceInput = ClawApproveInput & {
ttlSeconds: number;
};
export type ClawRejectInput = ClawApproveInput & {
reason?: string | null;
};
export type ClawExecuteInput = {
id: string;
grantId: string;
actorId: string;
};
export type BrokerExecutePayload = {
executionId: string;
approvalRequestId: string;
approvalGrantId: string;
exactCommand: string;
targetHost: string;
targetUser: string;
requestedBy: string;
channel: string;
chatId: string;
humanUserId: string;
sessionId: string;
};
export type BrokerExecuteResult = {
executionId: string;
status: string;
ok: boolean;
exitCode: number;
stdoutSummary?: string;
stderrSummary?: string;
startedAt: string;
finishedAt: string;
};
let pool: Pool | null = null;
function resolveEnv(name: string): string | undefined {
return process.env[`CLAW_${name}`] ?? process.env[name];
}
function requireEnv(name: string): string {
const v = resolveEnv(name);
if (!v || !v.trim()) {
throw new Error(`missing required environment variable: ${name} (or CLAW_${name})`);
}
return v;
}
function getPool(): Pool {
if (pool) {
return pool;
}
const host = requireEnv("PGHOST");
const portRaw = resolveEnv("PGPORT") ?? "5432";
const user = requireEnv("PGUSER");
const password = requireEnv("PGPASSWORD");
const database = requireEnv("PGDATABASE");
const port = Number(portRaw);
if (!Number.isFinite(port) || port <= 0) {
throw new Error(`invalid PGPORT: ${portRaw}`);
}
pool = new Pool({
host,
port,
user,
password,
database,
max: 10,
});
return pool;
}
export function normalizeCommand(input: string): string {
return input.trim().replace(/\s+/g, " ");
}
export function hasDangerousShellConstruct(command: string): boolean {
const source = command.toLowerCase();
const checks: RegExp[] = [
/\bbash\s+-c\b/,
/\bsh\s+-c\b/,
/\bsudo\s+su\b/,
/\bsudo\s+-i\b/,
/&&/,
/\|\|/,
/;/,
/\|/,
/>/,
/</,
/\$\(/,
/`/,
/<<[-\w]*/,
];
return checks.some((r) => r.test(source));
}
function mapRequestRow(row: Record<string, unknown>): ClawApprovalRequestRow {
return {
id: String(row.id),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
requestedByAgent: String(row.requested_by_agent),
sessionId: String(row.session_id),
channel: String(row.channel),
chatId: String(row.chat_id),
humanUserId: String(row.human_user_id),
targetHost: String(row.target_host),
targetUser: String(row.target_user),
cwd: (row.cwd as string | null) ?? null,
humanSummary: String(row.human_summary),
reason: String(row.reason),
exactCommand: String(row.exact_command),
normalizedCommand: String(row.normalized_command),
riskLevel: String(row.risk_level) as ClawRiskLevel,
rollbackHint: (row.rollback_hint as string | null) ?? null,
requiresPrivilege: Boolean(row.requires_privilege),
dangerousFlags: (row.dangerous_flags as Record<string, boolean> | null) ?? {},
status: String(row.status) as ClawApprovalStatus,
statusReason: (row.status_reason as string | null) ?? null,
approvedBy: (row.approved_by as string | null) ?? null,
approvedAt: (row.approved_at as string | null) ?? null,
rejectedBy: (row.rejected_by as string | null) ?? null,
rejectedAt: (row.rejected_at as string | null) ?? null,
expiredAt: (row.expired_at as string | null) ?? null,
executedAt: (row.executed_at as string | null) ?? null,
executionId: (row.execution_id as string | null) ?? null,
lastError: (row.last_error as string | null) ?? null,
};
}
async function insertAudit(
client: PoolClient,
args: {
eventType: string;
actorType: "agent" | "human" | "backend" | "broker" | "system";
actorId: string;
requestId?: string;
grantId?: string;
executionId?: string;
targetHost?: string;
targetUser?: string;
commandSnapshot?: string;
status?: string;
exitCode?: number;
stdoutSummary?: string;
stderrSummary?: string;
metadata?: Record<string, unknown>;
},
): Promise<void> {
await client.query(
`INSERT INTO claw_audit_events (
event_type,
request_id,
grant_id,
execution_id,
actor_type,
actor_id,
target_host,
target_user,
command_snapshot,
status,
exit_code,
stdout_summary,
stderr_summary,
metadata
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb
)`,
[
args.eventType,
args.requestId ?? null,
args.grantId ?? null,
args.executionId ?? null,
args.actorType,
args.actorId,
args.targetHost ?? null,
args.targetUser ?? null,
args.commandSnapshot ?? null,
args.status ?? null,
args.exitCode ?? null,
args.stdoutSummary ?? null,
args.stderrSummary ?? null,
JSON.stringify(args.metadata ?? {}),
],
);
}
export class ClawApprovalsStore {
async createApprovalRequest(
input: CreateClawApprovalRequestInput,
): Promise<ClawApprovalRequestRow> {
const normalizedCommand = normalizeCommand(input.exactCommand);
const dangerousFlags = {
hasDangerousShell: hasDangerousShellConstruct(input.exactCommand),
...input.dangerousFlags,
};
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const res = await client.query(
`INSERT INTO claw_approval_requests (
requested_by_agent, session_id, channel, chat_id, human_user_id,
target_host, target_user, cwd,
human_summary, reason, exact_command, normalized_command,
risk_level, rollback_hint, requires_privilege, dangerous_flags, status
) VALUES (
$1,$2,$3,$4,$5,
$6,$7,$8,
$9,$10,$11,$12,
$13,$14,TRUE,$15::jsonb,'pending'
) RETURNING *`,
[
input.requestedByAgent,
input.sessionId,
input.channel,
input.chatId,
input.humanUserId,
input.targetHost,
input.targetUser,
input.cwd ?? null,
input.humanSummary,
input.reason,
input.exactCommand,
normalizedCommand,
input.riskLevel,
input.rollbackHint ?? null,
JSON.stringify(dangerousFlags),
],
);
const row = mapRequestRow(res.rows[0]);
await insertAudit(client, {
eventType: "request_created",
actorType: "agent",
actorId: input.requestedByAgent,
requestId: row.id,
targetHost: row.targetHost,
targetUser: row.targetUser,
commandSnapshot: row.exactCommand,
status: row.status,
});
await client.query("COMMIT");
return row;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async listApprovalRequests(status?: string): Promise<ClawApprovalRequestRow[]> {
const db = getPool();
const res = await db.query(
status && status.trim().length > 0
? `SELECT * FROM claw_approval_requests WHERE status = $1 ORDER BY created_at DESC LIMIT 200`
: `SELECT * FROM claw_approval_requests ORDER BY created_at DESC LIMIT 200`,
status && status.trim().length > 0 ? [status.trim()] : [],
);
return res.rows.map(mapRequestRow);
}
async getApprovalRequest(id: string): Promise<ClawApprovalRequestRow | null> {
const db = getPool();
const res = await db.query(`SELECT * FROM claw_approval_requests WHERE id = $1 LIMIT 1`, [id]);
if (res.rowCount === 0) {
return null;
}
return mapRequestRow(res.rows[0]);
}
async approveOnce(
input: ClawApproveOnceInput,
): Promise<{ request: ClawApprovalRequestRow; grantId: string; expiresAt: string }> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const req = mapRequestRow(reqRes.rows[0]);
if (req.status !== "pending") {
throw new Error(`cannot approve_once from status=${req.status}`);
}
const ttl = Math.max(120, Math.min(300, Math.trunc(input.ttlSeconds || 180)));
const grantRes = await client.query(
`INSERT INTO claw_approval_grants (
request_id, grant_type, match_type,
target_host, target_user, channel, chat_id, human_user_id, session_id,
exact_command, normalized_command, approved_by, expires_at
) VALUES (
$1,'once','exact',$2,$3,$4,$5,$6,$7,$8,$9,$10, now() + ($11 || ' seconds')::interval
) RETURNING id, expires_at`,
[
req.id,
req.targetHost,
req.targetUser,
req.channel,
req.chatId,
req.humanUserId,
req.sessionId,
req.exactCommand,
req.normalizedCommand,
input.actorId,
String(ttl),
],
);
await client.query(
`UPDATE claw_approval_requests
SET status='approved_once', approved_by=$2, approved_at=now(), updated_at=now()
WHERE id = $1`,
[req.id, input.actorId],
);
await insertAudit(client, {
eventType: "request_approved_once",
actorType: "human",
actorId: input.actorId,
requestId: req.id,
grantId: String(grantRes.rows[0].id),
targetHost: req.targetHost,
targetUser: req.targetUser,
commandSnapshot: req.exactCommand,
status: "approved_once",
});
await client.query("COMMIT");
const next = await this.getApprovalRequest(req.id);
if (!next) {
throw new Error("approval request not found after approve_once");
}
return {
request: next,
grantId: String(grantRes.rows[0].id),
expiresAt: String(grantRes.rows[0].expires_at),
};
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async approveAlways(
input: ClawApproveInput,
): Promise<{ request: ClawApprovalRequestRow; grantId: string; allowRuleId: string }> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const req = mapRequestRow(reqRes.rows[0]);
if (req.status !== "pending") {
throw new Error(`cannot approve_always from status=${req.status}`);
}
if (hasDangerousShellConstruct(req.exactCommand)) {
throw new Error("always allow is forbidden for dangerous shell constructs");
}
const grantRes = await client.query(
`INSERT INTO claw_approval_grants (
request_id, grant_type, match_type,
target_host, target_user, channel, chat_id, human_user_id, session_id,
exact_command, normalized_command, approved_by
) VALUES (
$1,'always','exact',$2,$3,$4,$5,$6,$7,$8,$9,$10
) RETURNING id`,
[
req.id,
req.targetHost,
req.targetUser,
req.channel,
req.chatId,
req.humanUserId,
req.sessionId,
req.exactCommand,
req.normalizedCommand,
input.actorId,
],
);
const ruleRes = await client.query(
`INSERT INTO claw_allow_rules (
created_by, source_request_id,
target_host, target_user, channel, chat_id, human_user_id,
command_pattern_type, command_pattern, normalized_pattern, enabled
) VALUES (
$1,$2,$3,$4,$5,$6,$7,'exact',$8,$9,TRUE
)
ON CONFLICT ON CONSTRAINT uq_claw_allow_rules_active_exact
DO UPDATE SET updated_at=now(), enabled=TRUE
RETURNING id`,
[
input.actorId,
req.id,
req.targetHost,
req.targetUser,
req.channel,
req.chatId,
req.humanUserId,
req.exactCommand,
req.normalizedCommand,
],
);
await client.query(
`UPDATE claw_approval_requests
SET status='approved_always', approved_by=$2, approved_at=now(), updated_at=now()
WHERE id = $1`,
[req.id, input.actorId],
);
await insertAudit(client, {
eventType: "request_approved_always",
actorType: "human",
actorId: input.actorId,
requestId: req.id,
grantId: String(grantRes.rows[0].id),
targetHost: req.targetHost,
targetUser: req.targetUser,
commandSnapshot: req.exactCommand,
status: "approved_always",
metadata: { allowRuleId: String(ruleRes.rows[0].id) },
});
await client.query("COMMIT");
const next = await this.getApprovalRequest(req.id);
if (!next) {
throw new Error("approval request not found after approve_always");
}
return {
request: next,
grantId: String(grantRes.rows[0].id),
allowRuleId: String(ruleRes.rows[0].id),
};
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async reject(input: ClawRejectInput): Promise<ClawApprovalRequestRow> {
const db = getPool();
const client = await db.connect();
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
const req = mapRequestRow(reqRes.rows[0]);
if (req.status !== "pending") {
throw new Error(`cannot reject from status=${req.status}`);
}
await client.query(
`UPDATE claw_approval_requests
SET status='rejected', rejected_by=$2, rejected_at=now(), status_reason=$3, updated_at=now()
WHERE id = $1`,
[req.id, input.actorId, input.reason ?? null],
);
await insertAudit(client, {
eventType: "request_rejected",
actorType: "human",
actorId: input.actorId,
requestId: req.id,
targetHost: req.targetHost,
targetUser: req.targetUser,
commandSnapshot: req.exactCommand,
status: "rejected",
metadata: { reason: input.reason ?? null },
});
await client.query("COMMIT");
const next = await this.getApprovalRequest(req.id);
if (!next) {
throw new Error("approval request not found after reject");
}
return next;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
async executeApproved(
input: ClawExecuteInput,
runBroker: (payload: BrokerExecutePayload) => Promise<BrokerExecuteResult>,
): Promise<{
request: ClawApprovalRequestRow;
executionId: string;
broker: BrokerExecuteResult;
}> {
const db = getPool();
const client = await db.connect();
let request: ClawApprovalRequestRow | null = null;
try {
await client.query("BEGIN");
const reqRes = await client.query(
`SELECT * FROM claw_approval_requests WHERE id = $1 FOR UPDATE`,
[input.id],
);
if (reqRes.rowCount === 0) {
throw new Error("approval request not found");
}
request = mapRequestRow(reqRes.rows[0]);
if (request.status !== "approved_once" && request.status !== "approved_always") {
throw new Error(`cannot execute from status=${request.status}`);
}
const grantRes = await client.query(
`SELECT * FROM claw_approval_grants WHERE id = $1 AND request_id = $2 FOR UPDATE`,
[input.grantId, request.id],
);
if (grantRes.rowCount === 0) {
throw new Error("grant not found");
}
const grant = grantRes.rows[0] as Record<string, unknown>;
const exactCommand = String(grant.exact_command);
if (normalizeCommand(exactCommand) !== request.normalizedCommand) {
throw new Error("grant command mismatch with request");
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
if (!request) {
throw new Error("request resolution failed");
}
const broker = await runBroker({
executionId: randomUUID(),
approvalRequestId: request.id,
approvalGrantId: input.grantId,
exactCommand: request.exactCommand,
targetHost: request.targetHost,
targetUser: request.targetUser,
requestedBy: input.actorId,
channel: request.channel,
chatId: request.chatId,
humanUserId: request.humanUserId,
sessionId: request.sessionId,
});
const latest = await this.getApprovalRequest(request.id);
if (!latest) {
throw new Error("approval request not found after execution");
}
return {
request: latest,
executionId: broker.executionId,
broker,
};
}
async getAuditTrail(requestId: string): Promise<Record<string, unknown>[]> {
const db = getPool();
const res = await db.query(
`SELECT * FROM claw_audit_events WHERE request_id = $1 ORDER BY occurred_at ASC, id ASC`,
[requestId],
);
return res.rows as Record<string, unknown>[];
}
}
let singleton: ClawApprovalsStore | null = null;
export function getClawApprovalsStore(): ClawApprovalsStore {
if (!singleton) {
singleton = new ClawApprovalsStore();
}
return singleton;
}

View File

@@ -34,6 +34,14 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"exec.approval.request", "exec.approval.request",
"exec.approval.waitDecision", "exec.approval.waitDecision",
"exec.approval.resolve", "exec.approval.resolve",
"claw.approvals.create",
"claw.approvals.list",
"claw.approvals.get",
"claw.approvals.approveOnce",
"claw.approvals.approveAlways",
"claw.approvals.reject",
"claw.approvals.execute",
"claw.approvals.audit",
], ],
[PAIRING_SCOPE]: [ [PAIRING_SCOPE]: [
"node.pair.request", "node.pair.request",

View File

@@ -9,6 +9,7 @@ import { agentsHandlers } from "./server-methods/agents.js";
import { browserHandlers } from "./server-methods/browser.js"; import { browserHandlers } from "./server-methods/browser.js";
import { channelsHandlers } from "./server-methods/channels.js"; import { channelsHandlers } from "./server-methods/channels.js";
import { chatHandlers } from "./server-methods/chat.js"; import { chatHandlers } from "./server-methods/chat.js";
import { clawApprovalsHandlers } from "./server-methods/claw-approvals.js";
import { configHandlers } from "./server-methods/config.js"; import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js"; import { connectHandlers } from "./server-methods/connect.js";
import { cronHandlers } from "./server-methods/cron.js"; import { cronHandlers } from "./server-methods/cron.js";
@@ -76,6 +77,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...deviceHandlers, ...deviceHandlers,
...doctorHandlers, ...doctorHandlers,
...execApprovalsHandlers, ...execApprovalsHandlers,
...clawApprovalsHandlers,
...webHandlers, ...webHandlers,
...modelsHandlers, ...modelsHandlers,
...configHandlers, ...configHandlers,

View File

@@ -0,0 +1,330 @@
import {
getClawApprovalsStore,
type BrokerExecutePayload,
type BrokerExecuteResult,
type ClawRiskLevel,
} from "../claw-approvals-store.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
type JsonMap = Record<string, unknown>;
function asObject(value: unknown): JsonMap | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonMap) : null;
}
function getRequiredString(params: JsonMap, key: string): string {
const value = params[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${key} is required`);
}
return value.trim();
}
function getOptionalString(params: JsonMap, key: string): string | null {
const value = params[key];
if (value == null) {
return null;
}
if (typeof value !== "string") {
throw new Error(`${key} must be a string`);
}
return value.trim();
}
function getRiskLevel(params: JsonMap): ClawRiskLevel {
const value = params.riskLevel;
if (value === "low" || value === "medium" || value === "high") {
return value;
}
throw new Error("riskLevel must be low|medium|high");
}
async function invokeBroker(payload: BrokerExecutePayload): Promise<BrokerExecuteResult> {
const brokerUrl = process.env.CLAW_BROKER_URL ?? "http://127.0.0.1:8787/v1/execute";
const brokerToken = process.env.CLAW_BROKER_TOKEN?.trim();
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (brokerToken) {
headers.authorization = `Bearer ${brokerToken}`;
}
const res = await fetch(brokerUrl, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
let body: JsonMap | null = null;
try {
body = (await res.json()) as JsonMap;
} catch {
body = null;
}
if (!res.ok) {
const nowIso = new Date().toISOString();
return {
executionId: payload.executionId,
status: "broker_error",
ok: false,
exitCode: 1,
stderrSummary: `broker error ${res.status}`,
startedAt: nowIso,
finishedAt: nowIso,
};
}
const ok = body?.ok === true;
const exitCodeRaw = body?.exitCode;
const exitCode = typeof exitCodeRaw === "number" ? exitCodeRaw : ok ? 0 : 1;
const startedAt =
typeof body?.startedAt === "string" && body.startedAt.length > 0
? body.startedAt
: new Date().toISOString();
const finishedAt =
typeof body?.finishedAt === "string" && body.finishedAt.length > 0
? body.finishedAt
: startedAt;
return {
executionId:
typeof body?.executionId === "string" && body.executionId.length > 0
? body.executionId
: payload.executionId,
status: typeof body?.status === "string" ? body.status : ok ? "executed" : "execution_failed",
ok,
exitCode,
stdoutSummary: typeof body?.stdoutSummary === "string" ? body.stdoutSummary : "",
stderrSummary: typeof body?.stderrSummary === "string" ? body.stderrSummary : "",
startedAt,
finishedAt,
};
}
export const clawApprovalsHandlers: GatewayRequestHandlers = {
"claw.approvals.create": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const request = await store.createApprovalRequest({
requestedByAgent: getRequiredString(p, "requestedByAgent"),
sessionId: getRequiredString(p, "sessionId"),
channel: getRequiredString(p, "channel"),
chatId: getRequiredString(p, "chatId"),
humanUserId: getRequiredString(p, "humanUserId"),
targetHost: getRequiredString(p, "targetHost"),
targetUser: getRequiredString(p, "targetUser"),
cwd: getOptionalString(p, "cwd"),
humanSummary: getRequiredString(p, "humanSummary"),
reason: getRequiredString(p, "reason"),
exactCommand: getRequiredString(p, "exactCommand"),
riskLevel: getRiskLevel(p),
rollbackHint: getOptionalString(p, "rollbackHint"),
dangerousFlags: asObject(p.dangerousFlags) as Record<string, boolean> | undefined,
});
respond(true, { request }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.create failed: ${String(err)}`),
);
}
},
"claw.approvals.list": async ({ params, respond }) => {
try {
const p = asObject(params) ?? {};
const status = getOptionalString(p, "status");
const store = getClawApprovalsStore();
const requests = await store.listApprovalRequests(status ?? undefined);
respond(true, { requests }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.list failed: ${String(err)}`),
);
}
},
"claw.approvals.get": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const id = getRequiredString(p, "id");
const store = getClawApprovalsStore();
const request = await store.getApprovalRequest(id);
if (!request) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "request not found"));
return;
}
respond(true, { request }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.get failed: ${String(err)}`),
);
}
},
"claw.approvals.approveOnce": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const ttlRaw = p.ttlSeconds;
const ttlSeconds = typeof ttlRaw === "number" && Number.isFinite(ttlRaw) ? ttlRaw : 180;
const store = getClawApprovalsStore();
const result = await store.approveOnce({
id: getRequiredString(p, "id"),
actorId: getRequiredString(p, "actorId"),
ttlSeconds,
});
respond(
true,
{
request: result.request,
grant: {
grantId: result.grantId,
grantType: "once",
expiresAt: result.expiresAt,
},
},
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.approveOnce failed: ${String(err)}`),
);
}
},
"claw.approvals.approveAlways": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const result = await store.approveAlways({
id: getRequiredString(p, "id"),
actorId: getRequiredString(p, "actorId"),
});
respond(
true,
{
request: result.request,
grant: {
grantId: result.grantId,
grantType: "always",
},
allowRuleId: result.allowRuleId,
},
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`claw.approvals.approveAlways failed: ${String(err)}`,
),
);
}
},
"claw.approvals.reject": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const request = await store.reject({
id: getRequiredString(p, "id"),
actorId: getRequiredString(p, "actorId"),
reason: getOptionalString(p, "reason"),
});
respond(true, { request }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.reject failed: ${String(err)}`),
);
}
},
"claw.approvals.execute": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const store = getClawApprovalsStore();
const result = await store.executeApproved(
{
id: getRequiredString(p, "id"),
grantId: getRequiredString(p, "grantId"),
actorId: getRequiredString(p, "actorId"),
},
invokeBroker,
);
respond(
true,
{
request: result.request,
execution: {
executionId: result.executionId,
ok: result.broker.ok,
status: result.broker.status,
exitCode: result.broker.exitCode,
stdoutSummary: result.broker.stdoutSummary ?? "",
stderrSummary: result.broker.stderrSummary ?? "",
startedAt: result.broker.startedAt,
finishedAt: result.broker.finishedAt,
},
},
undefined,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `claw.approvals.execute failed: ${String(err)}`),
);
}
},
"claw.approvals.audit": async ({ params, respond }) => {
try {
const p = asObject(params);
if (!p) {
throw new Error("params object is required");
}
const id = getRequiredString(p, "id");
const store = getClawApprovalsStore();
const events = await store.getAuditTrail(id);
respond(true, { events }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `claw.approvals.audit failed: ${String(err)}`),
);
}
},
};