mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
Compare commits
531
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98fb167eb | ||
|
|
7b639505fe | ||
|
|
033f8f74d3 | ||
|
|
4f193d68e9 | ||
|
|
cb60a7a49d | ||
|
|
f24366cfe6 | ||
|
|
1f938a05e6 | ||
|
|
b07310243e | ||
|
|
fe349c338e | ||
|
|
0633e518e8 | ||
|
|
0facfa1ad7 | ||
|
|
21a5931fac | ||
|
|
72563c3b7e | ||
|
|
abf35f24ae | ||
|
|
1f8a68119b | ||
|
|
90d00a98cb | ||
|
|
6f6c4842b7 | ||
|
|
b385e17488 | ||
|
|
28de11d6e0 | ||
|
|
16f77da3b9 | ||
|
|
c38e779e55 | ||
|
|
699bab80ca | ||
|
|
7fed3a562e | ||
|
|
b79c99225d | ||
|
|
6fd9fb501f | ||
|
|
b655159866 | ||
|
|
63798718c7 | ||
|
|
1223a215b8 | ||
|
|
b45d0f198c | ||
|
|
7b72a45f6e | ||
|
|
e18f03638f | ||
|
|
e995e74eca | ||
|
|
02cf3900f9 | ||
|
|
5331b386d1 | ||
|
|
3a250916c6 | ||
|
|
b20e6e565c | ||
|
|
519750c1cc | ||
|
|
dde4c13955 | ||
|
|
f42859270b | ||
|
|
48f6e02548 | ||
|
|
d529294d37 | ||
|
|
f63e391151 | ||
|
|
f1242891f2 | ||
|
|
6787f24787 | ||
|
|
37c4028f0a | ||
|
|
bea46e45de | ||
|
|
c86be14d5c | ||
|
|
f3f6d00c0b | ||
|
|
585f3e0fd8 | ||
|
|
ddeed7df9e | ||
|
|
1de58e77b1 | ||
|
|
2806467322 | ||
|
|
073b48a46c | ||
|
|
cde52764cc | ||
|
|
d5e0397aea | ||
|
|
1c6a07f31b | ||
|
|
17ba9ba4a6 | ||
|
|
03241665a7 | ||
|
|
9d8399559b | ||
|
|
1ef799d610 | ||
|
|
4d5c38e877 | ||
|
|
5b4f2e8b99 | ||
|
|
3d9259c82c | ||
|
|
845e060df2 | ||
|
|
0209296308 | ||
|
|
2284667844 | ||
|
|
35eada2ea7 | ||
|
|
6e4b065e0e | ||
|
|
48e2cbfe11 | ||
|
|
bd8d3b1276 | ||
|
|
99f46076ee | ||
|
|
0583a098fc | ||
|
|
4e73e8b0b8 | ||
|
|
ad935b1b64 | ||
|
|
9d4a74e2b6 | ||
|
|
36521cf209 | ||
|
|
f1c2328caf | ||
|
|
a9982e624c | ||
|
|
856089c2f8 | ||
|
|
8b95ab8fe4 | ||
|
|
4f0cb71aeb | ||
|
|
6ad709e5a7 | ||
|
|
dccef749d2 | ||
|
|
6dced99a76 | ||
|
|
d36a03e8cc | ||
|
|
36f6ee62f3 | ||
|
|
456250e5b5 | ||
|
|
5151609f41 | ||
|
|
1c2f40786d | ||
|
|
dac63b4dab | ||
|
|
1dbecea76b | ||
|
|
0c45ea89c7 | ||
|
|
b211f0f3a5 | ||
|
|
83bdbde12b | ||
|
|
edbea73f01 | ||
|
|
cb1a3ea9ee | ||
|
|
1a3faa6ddb | ||
|
|
c9973228e5 | ||
|
|
4bedc6437b | ||
|
|
2e6a91f274 | ||
|
|
8ba2d6a663 | ||
|
|
6e57739734 | ||
|
|
61a4c18a02 | ||
|
|
49623ee625 | ||
|
|
c63b239720 | ||
|
|
b963e269ca | ||
|
|
383c7e02dd | ||
|
|
4a708fa00b | ||
|
|
fb3cf6a6c4 | ||
|
|
eee65eec9e | ||
|
|
c3fe72b51f | ||
|
|
031d3bd005 | ||
|
|
ce07e36223 | ||
|
|
b14ce69c3b | ||
|
|
caa4fa1803 | ||
|
|
210f3faf75 | ||
|
|
ca61f21477 | ||
|
|
b7895931ae | ||
|
|
d1f622ab17 | ||
|
|
667b4a4370 | ||
|
|
44538b5996 | ||
|
|
01f80e2dd4 | ||
|
|
d26fe76b88 | ||
|
|
629e528afe | ||
|
|
ab8e5d9cd1 | ||
|
|
09eb8a1319 | ||
|
|
957b492324 | ||
|
|
2df67a7c1c | ||
|
|
414fa82239 | ||
|
|
f61f8bf75f | ||
|
|
313394e46c | ||
|
|
d31cc99e0a | ||
|
|
c8a0be4716 | ||
|
|
33e5e61b0c | ||
|
|
73630f3f55 | ||
|
|
4b2ef79a61 | ||
|
|
24355016a0 | ||
|
|
32147fbbba | ||
|
|
ca18772f19 | ||
|
|
4d49a71272 | ||
|
|
7596fd4593 | ||
|
|
bc6303b5a3 | ||
|
|
08da207890 | ||
|
|
514284cc95 | ||
|
|
5b17505873 | ||
|
|
6f96b9da9a | ||
|
|
ef3817e404 | ||
|
|
430e468781 | ||
|
|
9775535c66 | ||
|
|
45506d43f4 | ||
|
|
300fc88c8c | ||
|
|
b79fe12bd8 | ||
|
|
7b0f792d3d | ||
|
|
7296d8aabd | ||
|
|
d3449556b7 | ||
|
|
4357648404 | ||
|
|
a67d64dcf5 | ||
|
|
bacde6d970 | ||
|
|
a78e1244f2 | ||
|
|
ecbd92ce2c | ||
|
|
43ebb786a2 | ||
|
|
1aeee5dc29 | ||
|
|
e473b2d6d8 | ||
|
|
4d2fa45db6 | ||
|
|
eb079221b2 | ||
|
|
93de9b0150 | ||
|
|
7970271d4f | ||
|
|
a4c724444c | ||
|
|
fec2934378 | ||
|
|
5606504563 | ||
|
|
0339ba85ba | ||
|
|
f9fcfd4edf | ||
|
|
775a78487d | ||
|
|
b5578007c4 | ||
|
|
5deeb3ce20 | ||
|
|
d25980bd8d | ||
|
|
b7ac7cc1a5 | ||
|
|
bee4d06917 | ||
|
|
e6b5b1ede5 | ||
|
|
cabeff509f | ||
|
|
65efe76a24 | ||
|
|
f289feb8c4 | ||
|
|
4af6086b20 | ||
|
|
17d365478f | ||
|
|
7acd2be925 | ||
|
|
1f908a0328 | ||
|
|
737c9d97ac | ||
|
|
b5d1d8c833 | ||
|
|
51834e6649 | ||
|
|
0bd52c14d7 | ||
|
|
2947420801 | ||
|
|
a95f6aaa6e | ||
|
|
ef05934b94 | ||
|
|
412b2ace24 | ||
|
|
55b175498a | ||
|
|
71e79a2f2c | ||
|
|
060adbd2cd | ||
|
|
f964d76855 | ||
|
|
e74a766284 | ||
|
|
e86fc1d225 | ||
|
|
fa2db928d0 | ||
|
|
04878d49c7 | ||
|
|
03e1b9396a | ||
|
|
1937688b07 | ||
|
|
ed89e3c4d1 | ||
|
|
bc2ae4ea94 | ||
|
|
e4284097f5 | ||
|
|
de7768bd8a | ||
|
|
e13a74a7b6 | ||
|
|
5f38cc0f1c | ||
|
|
a4bf8cc8e2 | ||
|
|
a6dbeaa724 | ||
|
|
f6120b0a41 | ||
|
|
30eec7fc98 | ||
|
|
a1d5be1050 | ||
|
|
91724b1a24 | ||
|
|
f22b058cff | ||
|
|
f5e3ceed7f | ||
|
|
35174dced2 | ||
|
|
8c7a3102f9 | ||
|
|
1b7552d3c3 | ||
|
|
669d3ebd47 | ||
|
|
87d41d6823 | ||
|
|
b19a08dbd4 | ||
|
|
3d2ed12e23 | ||
|
|
d7ad775092 | ||
|
|
dcc40d426e | ||
|
|
3dc42ee78d | ||
|
|
d2b366a62b | ||
|
|
88aca80db8 | ||
|
|
0699fd9fd7 | ||
|
|
0b69806328 | ||
|
|
a773268875 | ||
|
|
bbc7cf41a8 | ||
|
|
d2a48e7a6f | ||
|
|
6b0a5e2b6a | ||
|
|
1e24133475 | ||
|
|
f86d2dc0a0 | ||
|
|
d1ffc251b3 | ||
|
|
955556d784 | ||
|
|
7e32ec6aab | ||
|
|
1dda4a5ef5 | ||
|
|
1c4922a017 | ||
|
|
d7a08d0399 | ||
|
|
aa7ff104c2 | ||
|
|
85eebc52b0 | ||
|
|
b0d5b68c5c | ||
|
|
c226d7405c | ||
|
|
f4f720ebc7 | ||
|
|
1c92840a60 | ||
|
|
89950992c5 | ||
|
|
8748afedaf | ||
|
|
60ca7aa9df | ||
|
|
ab924a258a | ||
|
|
21e543d856 | ||
|
|
496ad5dd6f | ||
|
|
ee25d1319a | ||
|
|
0fd905ba4f | ||
|
|
f23c644a16 | ||
|
|
f1ebce4637 | ||
|
|
8f77de21f5 | ||
|
|
472438973c | ||
|
|
ea1929e4e0 | ||
|
|
a0224dabe8 | ||
|
|
de54f0065a | ||
|
|
18c4761082 | ||
|
|
6950bed1a4 | ||
|
|
a4491f8d40 | ||
|
|
7a2d38ef26 | ||
|
|
26b4d35bc1 | ||
|
|
baaa203d4e | ||
|
|
fcad507f5f | ||
|
|
53e077ec68 | ||
|
|
498557ccbc | ||
|
|
ad13209dda | ||
|
|
bf03da7f84 | ||
|
|
84a3761582 | ||
|
|
b453a96cae | ||
|
|
2f58b02218 | ||
|
|
1cee6270bf | ||
|
|
16b868f6f1 | ||
|
|
8767418326 | ||
|
|
3e73d8a81c | ||
|
|
b11ef9efb6 | ||
|
|
62a2085a23 | ||
|
|
eeaec4705c | ||
|
|
7cfb2e9cf9 | ||
|
|
c2254a2fed | ||
|
|
da3c38e8bc | ||
|
|
c7b016d726 | ||
|
|
e876a074b2 | ||
|
|
c4522306b8 | ||
|
|
044c770569 | ||
|
|
e17c230322 | ||
|
|
955250dcc9 | ||
|
|
bb67fa2b92 | ||
|
|
7ecc9d53a4 | ||
|
|
f42f6a82fb | ||
|
|
b9fb607380 | ||
|
|
dd72e81406 | ||
|
|
75038ea2cb | ||
|
|
e1d54f11f8 | ||
|
|
2534396d82 | ||
|
|
7519ff850c | ||
|
|
e5c9480ed2 | ||
|
|
ce53702473 | ||
|
|
bc6bb1699f | ||
|
|
5132cc36ec | ||
|
|
d3be648fa2 | ||
|
|
65c2103f4f | ||
|
|
c59f79e440 | ||
|
|
796d538582 | ||
|
|
ce76cce9ee | ||
|
|
8c86f9cc43 | ||
|
|
6ee13a42ad | ||
|
|
9ad20cadcf | ||
|
|
3cfb7bb96c | ||
|
|
03bf88bcd3 | ||
|
|
625a0c87cc | ||
|
|
66e964e934 | ||
|
|
4841fc6394 | ||
|
|
264e412783 | ||
|
|
8e67c10c8b | ||
|
|
40a6347aee | ||
|
|
c7226b697c | ||
|
|
35339ff419 | ||
|
|
a7ca0a335a | ||
|
|
11c5b95a32 | ||
|
|
759a5b8f1c | ||
|
|
40c32eb12c | ||
|
|
6d769f4d97 | ||
|
|
f3b48e97fb | ||
|
|
b95864ab9e | ||
|
|
88489aeafb | ||
|
|
427e7cf960 | ||
|
|
75b9e13d1a | ||
|
|
67c89077c3 | ||
|
|
8ec139f207 | ||
|
|
873c0b08ba | ||
|
|
2288c20eae | ||
|
|
87e3486116 | ||
|
|
3f4691aa19 | ||
|
|
9ac8ebc001 | ||
|
|
6ef491ea50 | ||
|
|
e586cd7f61 | ||
|
|
21e8e37a3e | ||
|
|
8b65094a24 | ||
|
|
66518c8a0e | ||
|
|
88a02e539a | ||
|
|
00f9c62d8e | ||
|
|
2608166895 | ||
|
|
66de693618 | ||
|
|
82cff89bb9 | ||
|
|
c6e984ed6d | ||
|
|
dc26a440b5 | ||
|
|
7aa9a771b1 | ||
|
|
f1820b76fc | ||
|
|
4682657644 | ||
|
|
5ba69a2800 | ||
|
|
ef4068d376 | ||
|
|
916a9000af | ||
|
|
3a1820a1ff | ||
|
|
3f19ab1188 | ||
|
|
6ecd14741e | ||
|
|
2c7ab780fa | ||
|
|
ae9719e648 | ||
|
|
3ec2609f6d | ||
|
|
4a2379e5ac | ||
|
|
0deec6da1c | ||
|
|
532a15127f | ||
|
|
91e47f74f9 | ||
|
|
5ec718c460 | ||
|
|
18ce29ab25 | ||
|
|
8fa934e5dc | ||
|
|
b687d7a188 | ||
|
|
451fc34e8b | ||
|
|
1993b97f63 | ||
|
|
f2138c87f8 | ||
|
|
96a7b00d6c | ||
|
|
7303e0accf | ||
|
|
3069ef13e3 | ||
|
|
36d4858e4c | ||
|
|
b1a7ab006c | ||
|
|
400aaac0d3 | ||
|
|
ce121e0c65 | ||
|
|
938ae01b32 | ||
|
|
d9e8a2e246 | ||
|
|
1af7264926 | ||
|
|
5acb924d9c | ||
|
|
c573f9dc97 | ||
|
|
463e90c67b | ||
|
|
53a47357ec | ||
|
|
5bdb59001a | ||
|
|
fb52c2b758 | ||
|
|
5b93ea1db6 | ||
|
|
33d1f1b240 | ||
|
|
b99e9ce521 | ||
|
|
e468a603b5 | ||
|
|
31ed2bb54e | ||
|
|
98f2569bca | ||
|
|
7c5107b654 | ||
|
|
8caf04d469 | ||
|
|
d6ec2e7b11 | ||
|
|
8781808eb5 | ||
|
|
3f4cba1b32 | ||
|
|
33742e3ef0 | ||
|
|
250ace73fb | ||
|
|
14933330de | ||
|
|
60094acad5 | ||
|
|
5a548234c6 | ||
|
|
c6c92b6991 | ||
|
|
2574c54435 | ||
|
|
145f99f782 | ||
|
|
e1d440c46f | ||
|
|
a4b737c2b6 | ||
|
|
61c85fe3eb | ||
|
|
1e8310afe4 | ||
|
|
2c3baba9a1 | ||
|
|
7625fbf80a | ||
|
|
3ba16c0ca4 | ||
|
|
85473d5027 | ||
|
|
222c0f0d70 | ||
|
|
3b835b9986 | ||
|
|
7f29b5c278 | ||
|
|
90e84acbf8 | ||
|
|
58cdb273c1 | ||
|
|
4d38708fb2 | ||
|
|
08c65f73b8 | ||
|
|
6129465940 | ||
|
|
e5cca8eb23 | ||
|
|
f25c058f91 | ||
|
|
42e732cc7e | ||
|
|
366e8d2f2d | ||
|
|
c227957586 | ||
|
|
6043aea0e3 | ||
|
|
969c76c62d | ||
|
|
2a2f72da0b | ||
|
|
3c5ceaa067 | ||
|
|
e8a101cd72 | ||
|
|
2faf23d79b | ||
|
|
598791452c | ||
|
|
51cd04b4eb | ||
|
|
708d0a7ce9 | ||
|
|
4adf5a1f2f | ||
|
|
11769fbd1b | ||
|
|
976faaaebd | ||
|
|
ea0b457e6a | ||
|
|
867cb6fc1f | ||
|
|
22c37cb9e6 | ||
|
|
d57a334627 | ||
|
|
2b2b7967b8 | ||
|
|
a027b09d3b | ||
|
|
20aa7da3d2 | ||
|
|
97553dbaad | ||
|
|
126771f45a | ||
|
|
7e13d887c7 | ||
|
|
c392315c1f | ||
|
|
8823d56891 | ||
|
|
e261e8c772 | ||
|
|
f70cb8586e | ||
|
|
cb9b613863 | ||
|
|
be913d3cf8 | ||
|
|
a7d782ebee | ||
|
|
f3c17e224f | ||
|
|
dffdc50a98 | ||
|
|
e0dac77e6e | ||
|
|
cce96a3745 | ||
|
|
d7e26dc586 | ||
|
|
20ec714fe7 | ||
|
|
92b679d437 | ||
|
|
ff8e82dca5 | ||
|
|
bdac092227 | ||
|
|
86c1fcf2be | ||
|
|
6bc0d0be27 | ||
|
|
fbb7c3f4a3 | ||
|
|
d5dee3e9e8 | ||
|
|
95cab302ed | ||
|
|
7cd18a1868 | ||
|
|
a7ada8bd24 | ||
|
|
e5dd46e534 | ||
|
|
9b16e411d1 | ||
|
|
9f354df680 | ||
|
|
23ace6aac2 | ||
|
|
aabca80fee | ||
|
|
9008ed854c | ||
|
|
b04103ca67 | ||
|
|
728f7380c1 | ||
|
|
20e98f8255 | ||
|
|
7cbfecc2d5 | ||
|
|
713f817e3a | ||
|
|
ec45205fce | ||
|
|
833e4dfdce | ||
|
|
30e8dac403 | ||
|
|
b6861369e7 | ||
|
|
24912a6c88 | ||
|
|
af9f5285ed | ||
|
|
1a3d924ffe | ||
|
|
cd3fa478e6 | ||
|
|
8ccb34c80a | ||
|
|
2c4bb3e1aa | ||
|
|
cd2d33333e | ||
|
|
83b9e4aff9 | ||
|
|
70a42e58c8 | ||
|
|
02a60736c3 | ||
|
|
96ff90ce9e | ||
|
|
cb69323964 | ||
|
|
342ce6706d | ||
|
|
29baf39564 | ||
|
|
a546c902ba | ||
|
|
0d42b01cfa | ||
|
|
3ebf2248b1 | ||
|
|
e56561f1eb | ||
|
|
8974c9f2c1 | ||
|
|
6f9ab1722c | ||
|
|
675f14cc56 | ||
|
|
2aa39f744e | ||
|
|
d5cad65ed8 | ||
|
|
1cdca6c8fe | ||
|
|
23a11a3cc6 | ||
|
|
56903552ef | ||
|
|
7763491ee2 | ||
|
|
667aa34c6b | ||
|
|
13517bad1a | ||
|
|
37b68ecb92 | ||
|
|
ac505e6f5e | ||
|
|
7a36a68034 | ||
|
|
f54f587719 | ||
|
|
312fc936cf | ||
|
|
69d38a13ee | ||
|
|
08ce23f410 | ||
|
|
344a8e3db7 |
@@ -6,9 +6,6 @@ on:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -21,23 +18,19 @@ jobs:
|
||||
target: macos-x86_64
|
||||
- os: ubuntu-22.04
|
||||
target: linux-x86_64
|
||||
- os: ubuntu-22.04-arm
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
target: windows-x86_64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
@@ -45,20 +38,6 @@ jobs:
|
||||
uv sync --frozen
|
||||
uv run pyinstaller strix.spec --noconfirm
|
||||
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
dist/strix.exe --version
|
||||
else
|
||||
dist/strix --version
|
||||
fi
|
||||
|
||||
if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then
|
||||
file dist/strix
|
||||
file dist/strix | grep -q "ARM aarch64" || {
|
||||
echo "::error::linux-arm64 artifact is not an ARM aarch64 binary"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
mkdir -p dist/release
|
||||
|
||||
@@ -71,7 +50,7 @@ jobs:
|
||||
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: strix-${{ matrix.target }}
|
||||
path: |
|
||||
@@ -86,13 +65,13 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
generate_release_notes: true
|
||||
|
||||
+4
-12
@@ -1,25 +1,17 @@
|
||||
# Node / local-viewer SPA source (the built bundle in
|
||||
# strix/interface/viewer/static/ is committed and shipped; do not ignore it)
|
||||
node_modules/
|
||||
strix/interface/viewer/frontend/node_modules/
|
||||
strix/interface/viewer/frontend/.vite/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
# Anchored to the repo root: these are Python build-artifact dir names, but
|
||||
# unanchored they also match nested source dirs (e.g. the viewer's src/lib).
|
||||
/build/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
/lib/
|
||||
/lib64/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -54,7 +46,7 @@ pip-delete-this-directory.txt
|
||||
.env.production.local
|
||||
|
||||
# MongoDB
|
||||
/data/
|
||||
data/
|
||||
mongod.log
|
||||
*.mongodb
|
||||
*.mongorc.js
|
||||
|
||||
@@ -99,20 +99,6 @@ We welcome feature ideas! Please:
|
||||
- Consider implementation approach
|
||||
- Be open to discussion
|
||||
|
||||
## 🖥️ Local viewer SPA
|
||||
|
||||
`strix view` serves a prebuilt web UI whose source lives in
|
||||
`strix/interface/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||
committed to `strix/interface/viewer/static/` and shipped in the package. End users never
|
||||
run a JS build. If you change anything under `strix/interface/viewer/frontend/`, rebuild
|
||||
and commit the output:
|
||||
|
||||
```bash
|
||||
make viewer # or: cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
```
|
||||
|
||||
Commit both the source change and the regenerated `strix/interface/viewer/static/`.
|
||||
|
||||
## 🤝 Community
|
||||
|
||||
- **Discord**: [Join our community](https://discord.gg/strix-ai)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev viewer
|
||||
.PHONY: help install dev-install format lint type-check security check-all clean pre-commit setup-dev dev
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@@ -15,7 +15,6 @@ help:
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " pre-commit - Run pre-commit hooks on all files"
|
||||
@echo " viewer - Rebuild the local-viewer SPA (commit the output)"
|
||||
@echo " clean - Clean up cache files and artifacts"
|
||||
|
||||
install:
|
||||
@@ -67,10 +66,5 @@ clean:
|
||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||
@echo "✅ Cleanup complete!"
|
||||
|
||||
viewer:
|
||||
@echo "🖥️ Building the local-viewer SPA..."
|
||||
cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||
@echo "✅ Viewer built to strix/interface/viewer/static/ (commit the changes)."
|
||||
|
||||
dev: format lint type-check
|
||||
@echo "✅ Development cycle complete!"
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
<a href="https://x.com/strix_ai"><img src="https://github.com/usestrix/.github/raw/main/imgs/X.png" height="40" alt="Follow on X"></a>
|
||||
|
||||
|
||||
<a href="https://trendshift.io/repositories/15362?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-15362" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/15362/weekly" alt="usestrix%2Fstrix | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/15362" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15362" alt="usestrix/strix | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/15362?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-15362" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/15362/weekly" alt="usestrix%2Fstrix | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -145,31 +145,6 @@ Advanced multi-agent orchestration for comprehensive automated penetration testi
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Local Web Viewer
|
||||
|
||||
Every scan writes its results to disk as it runs. Bring them up in a local dashboard with a single command:
|
||||
|
||||
```bash
|
||||
# Open the most recent run
|
||||
strix view
|
||||
|
||||
# ...or open a specific run by name
|
||||
strix view my-run-name
|
||||
```
|
||||
|
||||
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
|
||||
|
||||
### What's in the dashboard
|
||||
|
||||
- **Overview**: run status, target, and a severity breakdown of everything found so far.
|
||||
- **Vulnerabilities**: each validated finding with its severity, details, and reproduction steps.
|
||||
- **Agent graph**: a live map of the multi-agent team, showing which agent is doing what.
|
||||
- **Steering**: send instructions to a live scan from the browser to redirect the agents mid-run.
|
||||
- **History**: browse past runs on this machine and jump between them.
|
||||
- **Reports**: generate a shareable report and email it to yourself or your team.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
@@ -267,20 +242,6 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high,
|
||||
> [!NOTE]
|
||||
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
||||
|
||||
#### Sign in with a ChatGPT subscription
|
||||
|
||||
Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription:
|
||||
|
||||
```bash
|
||||
strix auth login chatgpt # sign in with your ChatGPT account
|
||||
|
||||
export STRIX_LLM="chatgpt/gpt-5.4" # chatgpt/<model> runs on the subscription
|
||||
strix --target ./app-directory
|
||||
|
||||
strix auth status # show the active sign-in
|
||||
strix auth logout # forget the sign-in
|
||||
```
|
||||
|
||||
**Recommended models for best results:**
|
||||
|
||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||
|
||||
+17
-57
@@ -1,26 +1,3 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builder stage: compile the Go tools here so the Go toolchain (~225MB) and the
|
||||
# module/build caches never reach the runtime image. The resulting binaries are
|
||||
# statically linked and copied into the final stage.
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM kalilinux/kali-rolling:latest AS gobuilder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y kali-archive-keyring && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends golang-go git ca-certificates
|
||||
|
||||
ENV GOBIN=/out/bin
|
||||
RUN mkdir -p /out/bin && \
|
||||
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime stage
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM kalilinux/kali-rolling:latest
|
||||
|
||||
LABEL description="AI Agent Penetration Testing Environment with Comprehensive Automated Tools"
|
||||
@@ -42,13 +19,13 @@ RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl git vim nano unzip tar \
|
||||
apt-transport-https ca-certificates gnupg lsb-release \
|
||||
software-properties-common \
|
||||
gcc libc6-dev \
|
||||
python3 python3-pip python3-venv python3-setuptools \
|
||||
build-essential software-properties-common \
|
||||
gcc libc6-dev pkg-config libpcap-dev libssl-dev \
|
||||
python3 python3-pip python3-dev python3-venv python3-setuptools \
|
||||
golang-go \
|
||||
net-tools dnsutils whois \
|
||||
file xxd \
|
||||
jq parallel ripgrep grep \
|
||||
less procps htop \
|
||||
less man-db procps htop \
|
||||
iproute2 iputils-ping netcat-traditional \
|
||||
nmap ncat ndiff \
|
||||
sqlmap nuclei subfinder naabu ffuf \
|
||||
@@ -88,8 +65,11 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
|
||||
USER pentester
|
||||
WORKDIR /tmp
|
||||
|
||||
# Go tools are built in the gobuilder stage; copy the static binaries only.
|
||||
COPY --from=gobuilder --chown=pentester:pentester /out/bin/ /home/pentester/go/bin/
|
||||
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest && \
|
||||
go install -v github.com/projectdiscovery/katana/cmd/katana@latest && \
|
||||
go install -v github.com/projectdiscovery/cvemap/cmd/vulnx@latest && \
|
||||
go install -v github.com/jaeles-project/gospider@latest && \
|
||||
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
|
||||
|
||||
RUN nuclei -update-templates
|
||||
|
||||
@@ -106,10 +86,7 @@ RUN npm install -g retire@latest && \
|
||||
npm install -g js-beautify@latest && \
|
||||
npm install -g @ast-grep/cli@latest && \
|
||||
npm install -g tree-sitter-cli@latest && \
|
||||
npm install -g agent-browser@0.26.0 && \
|
||||
npm cache clean --force && \
|
||||
# ast-grep ships two identical binaries (`ast-grep` and `sg`); dedupe (~52MB)
|
||||
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
|
||||
npm install -g agent-browser@0.26.0
|
||||
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
@@ -154,14 +131,7 @@ RUN git clone https://github.com/aravind0x7/JS-Snooper.git && \
|
||||
|
||||
USER root
|
||||
|
||||
# Install trufflehog into a pentester-owned dir on PATH so its runtime self-update
|
||||
# (which replaces the binary in place) succeeds: as non-root `pentester` it cannot
|
||||
# overwrite a root-owned binary under /usr/local/bin, which otherwise fails with
|
||||
# "cannot move binary" and aborts the scan. Pin the initial version for
|
||||
# reproducible builds; self-update then pulls fresh detectors at runtime.
|
||||
ARG TRUFFLEHOG_VERSION=3.95.9
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /home/pentester/.local/bin "v${TRUFFLEHOG_VERSION}" && \
|
||||
chown -R pentester:pentester /home/pentester/.local
|
||||
RUN curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin
|
||||
RUN set -eux; \
|
||||
ARCH="$(uname -m)"; \
|
||||
case "$ARCH" in \
|
||||
@@ -175,6 +145,8 @@ RUN set -eux; \
|
||||
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks; \
|
||||
rm -f /tmp/gitleaks /tmp/gitleaks.tgz
|
||||
|
||||
RUN apt-get update && apt-get install -y zaproxy
|
||||
|
||||
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
|
||||
RUN apt-get install -y wapiti
|
||||
@@ -190,12 +162,7 @@ USER root
|
||||
|
||||
RUN apt-get autoremove -y && \
|
||||
apt-get autoclean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
# Purge non-English locales (~160MB)
|
||||
find /usr/share/locale -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name 'en' ! -name 'en_US' ! -name 'C' -exec rm -rf {} + && \
|
||||
# Remove package documentation and man pages not needed at runtime (~95MB)
|
||||
rm -rf /usr/share/doc/* /usr/share/doc-base/* /usr/share/man/*
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
ENV PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"
|
||||
ENV VIRTUAL_ENV="/app/.venv"
|
||||
@@ -225,8 +192,6 @@ RUN mkdir -p /workspace && chown -R pentester:pentester /workspace /app
|
||||
USER pentester
|
||||
RUN python3 -m venv /app/.venv && \
|
||||
/app/.venv/bin/pip install --no-cache-dir caido-sdk-client && \
|
||||
/app/.venv/bin/pip install --no-cache-dir \
|
||||
requests httpx beautifulsoup4 lxml pyjwt cryptography && \
|
||||
/app/.venv/bin/pip install --no-cache-dir -r /home/pentester/tools/jwt_tool/requirements.txt && \
|
||||
printf '%s\n' \
|
||||
'#!/bin/bash' \
|
||||
@@ -237,13 +202,8 @@ RUN python3 -m venv /app/.venv && \
|
||||
COPY --chown=pentester:pentester strix/tools/proxy/caido_api.py /opt/strix-python/caido_api.py
|
||||
ENV PYTHONPATH=/opt/strix-python
|
||||
|
||||
# Login shells (e.g. `bash -lc`) source /etc/profile, which on Debian/Kali
|
||||
# hard-resets PATH and drops the image's ENV PATH entries. Re-add the same
|
||||
# directories here — including /app/.venv/bin — so `python3`/`pip` resolve to
|
||||
# the venv (which ships requests, httpx, bs4, lxml, pyjwt, cryptography, and the
|
||||
# Caido SDK) instead of the externally-managed system interpreter.
|
||||
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.bashrc && \
|
||||
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:/app/.venv/bin:$PATH"' >> /home/pentester/.profile
|
||||
RUN echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.bashrc && \
|
||||
echo 'export PATH="/home/pentester/go/bin:/home/pentester/.local/bin:/home/pentester/.npm-global/bin:$PATH"' >> /home/pentester/.profile
|
||||
|
||||
USER root
|
||||
COPY containers/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
@@ -91,13 +91,10 @@ http_proxy=http://127.0.0.1:${CAIDO_PORT}
|
||||
https_proxy=http://127.0.0.1:${CAIDO_PORT}
|
||||
EOF
|
||||
|
||||
# Use POSIX `.` (not the bashism `source`) so these lines are safe when the rc
|
||||
# files are read by a POSIX shell (e.g. `sh -lc`), which otherwise fails with
|
||||
# "source: not found". `.` is understood by bash, zsh, and dash alike.
|
||||
echo ". /etc/profile.d/proxy.sh" >> ~/.bashrc
|
||||
echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc
|
||||
echo "source /etc/profile.d/proxy.sh" >> ~/.bashrc
|
||||
echo "source /etc/profile.d/proxy.sh" >> ~/.zshrc
|
||||
|
||||
. /etc/profile.d/proxy.sh
|
||||
source /etc/profile.d/proxy.sh
|
||||
|
||||
echo "✅ System-wide proxy configuration complete"
|
||||
|
||||
|
||||
@@ -35,31 +35,6 @@ Configure Strix using environment variables or a config file.
|
||||
Timeout in seconds for memory compression operations (context summarization).
|
||||
</ParamField>
|
||||
|
||||
### Dedicated deduplication model
|
||||
|
||||
Finding deduplication is a cheap, structured classification task. By default it
|
||||
runs on the main model, but you can route it to a smaller/cheaper model without
|
||||
affecting the agents that do the actual testing.
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
|
||||
Model used to judge whether a candidate finding duplicates an existing report.
|
||||
Falls back to `STRIX_LLM` when unset.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
|
||||
Optional provider key for the deduplication model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
|
||||
Optional custom API base URL for the deduplication model. Use when the dedupe
|
||||
model runs on a different endpoint than the main model.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
|
||||
Reasoning effort for the deduplication model. Defaults to the model's own
|
||||
baseline when unset.
|
||||
</ParamField>
|
||||
|
||||
## Optional Features
|
||||
|
||||
<ParamField path="PERPLEXITY_API_KEY" type="string">
|
||||
|
||||
@@ -81,14 +81,6 @@ Protocol-specific testing techniques.
|
||||
| --------- | ------------------------------------------------ |
|
||||
| `graphql` | GraphQL introspection, batching, resolver issues |
|
||||
|
||||
### Reconnaissance
|
||||
|
||||
Passive discovery and attack-surface mapping techniques.
|
||||
|
||||
| Skill | Coverage |
|
||||
| ----------------- | --------------------------------------------------------------- |
|
||||
| `asset_discovery` | CT, TLS SAN pivoting, passive DNS, and ASN/IP asset enumeration |
|
||||
|
||||
### Tooling
|
||||
|
||||
Sandbox CLI playbooks for core recon and scanning tools.
|
||||
|
||||
+4
-38
@@ -61,28 +61,11 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
|
||||
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--max-budget" type="number">
|
||||
<ParamField path="--max-budget-usd" type="number">
|
||||
Maximum LLM spend in USD for the whole scan, counted cumulatively across the
|
||||
root agent and every child agent. The budget is checked after each model
|
||||
response.
|
||||
|
||||
In non-interactive mode (`-n`), once the running cost reaches the threshold,
|
||||
the scan stops cleanly with a `stopped` status (not a failure) and the sandbox
|
||||
is torn down. Sub-agents are stopped early, at 90% of the budget, reserving
|
||||
the final slice for the root agent to wind down and produce the final report.
|
||||
|
||||
In interactive mode, reaching the budget pauses the scan instead of ending
|
||||
it: every agent parks, and sending any message resumes the scan with the cap
|
||||
extended by the original budget amount. There is no sub-agent reserve in
|
||||
interactive mode.
|
||||
|
||||
As the budget is approached, graduated wrap-up warnings are surfaced to
|
||||
**every** agent so they can finish their work and call their lifecycle tool
|
||||
before the hard stop. The bands sit just below each role's own stop point: the
|
||||
root is warned at **70%, 85% and 95%** (it stops at 100%), while sub-agents are
|
||||
warned at **75%, 80% and 85%** (they stop at the 90% reserve). In interactive
|
||||
mode every agent uses the **70%, 85% and 95%** bands. Percentages shown in the
|
||||
warnings are the real cumulative spend against the full budget.
|
||||
response; once the running cost reaches the threshold, the scan stops cleanly
|
||||
with a `stopped` status (not a failure) and the sandbox is torn down.
|
||||
|
||||
Must be greater than `0`. Omit the flag for no limit.
|
||||
|
||||
@@ -101,19 +84,6 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
|
||||
counts.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="--max-turns" type="integer" default="500">
|
||||
Maximum number of turns (one model response plus its tool round) allotted to
|
||||
**each** agent, applied per run. When an agent reaches this limit it is
|
||||
force-stopped.
|
||||
|
||||
As the limit is approached, graduated wrap-up warnings (at 70%, 85% and 95%)
|
||||
are injected into that agent's next model turn so it can prioritise its
|
||||
remaining work and call its lifecycle tool (`finish_scan` for the root agent,
|
||||
`agent_finish` for sub-agents) before the hard stop.
|
||||
|
||||
Must be greater than `0`.
|
||||
</ParamField>
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
@@ -129,9 +99,6 @@ strix --target api.example.com --instruction "Focus on IDOR and auth bypass"
|
||||
# CI/CD mode
|
||||
strix -n --target ./ --scan-mode quick
|
||||
|
||||
# Cap cost and per-agent turns
|
||||
strix --target https://example.com --max-budget 25 --max-turns 300
|
||||
|
||||
# Force diff-scope against a specific base ref
|
||||
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||
|
||||
@@ -149,6 +116,5 @@ strix --mount ./huge-monorepo
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Scan completed successfully (interactive mode always exits `0`; in headless mode, `0` means no vulnerabilities were found) |
|
||||
| 1 | A fatal error occurred before or during the scan (e.g. missing environment variables, Docker unavailable, invalid config file, diff-scope resolution failure, or an unhandled error) |
|
||||
| 0 | Scan completed, no vulnerabilities found |
|
||||
| 2 | Vulnerabilities found (headless mode only) |
|
||||
|
||||
+2
-36
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.4.1"
|
||||
version = "1.0.4"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -34,8 +34,6 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"openai-agents[litellm]==0.14.6",
|
||||
"openai>=2.26.0,<2.45",
|
||||
"litellm",
|
||||
"pydantic>=2.11.3",
|
||||
"pydantic-settings>=2.13.0",
|
||||
"rich",
|
||||
@@ -44,11 +42,6 @@ dependencies = [
|
||||
"requests>=2.32.0",
|
||||
"cvss>=3.2",
|
||||
"caido-sdk-client>=0.2.0",
|
||||
"reportlab>=4.0",
|
||||
"pypdf>=5.0",
|
||||
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
|
||||
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
|
||||
"cryptography>=48.0.1,<49",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -79,10 +72,6 @@ build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["strix"]
|
||||
# The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically
|
||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||
# under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel.
|
||||
exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"]
|
||||
|
||||
# ============================================================================
|
||||
# Type Checking Configuration
|
||||
@@ -120,9 +109,6 @@ module = [
|
||||
"docker.*",
|
||||
"caido_sdk_client.*",
|
||||
"pydantic_settings.*",
|
||||
"reportlab.*",
|
||||
"pypdf.*",
|
||||
"pygments.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
@@ -213,19 +199,6 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||
"strix/interface/auth_cli.py" = ["N802"]
|
||||
"tests/test_codex_streaming.py" = ["N802"]
|
||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/interface/viewer/cli.py" = ["PLC0415"]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
# strix.telemetry / strix.report.dedupe / cvss.
|
||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||
@@ -258,16 +231,9 @@ ignore = [
|
||||
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
||||
# ReportState carries scan artifact/report fields and
|
||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
|
||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
|
||||
"strix/report/usage.py" = ["PLC0415"]
|
||||
# Lazy import of strix.config.models avoids a circular dependency between the
|
||||
# report pipeline and the config layer.
|
||||
"strix/report/dedupe.py" = ["PLC0415"]
|
||||
"strix/telemetry/logging.py" = ["PLC0415"]
|
||||
"strix/config/models.py" = ["PLC0415"]
|
||||
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
|
||||
# don't pull them in.
|
||||
"strix/config/codex.py" = ["PLC0415"]
|
||||
# Interface utility branches per scope-mode / target-type combination;
|
||||
# splitting would obscure the decision tree without simplifying it.
|
||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
APP=strix
|
||||
REPO="usestrix/strix"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0"
|
||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
@@ -41,7 +41,7 @@ fi
|
||||
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x86_64|linux-arm64|macos-x86_64|macos-arm64|windows-x86_64)
|
||||
linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64)
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
|
||||
+1
-32
@@ -25,13 +25,6 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
||||
rel_path = tcss_file.relative_to(project_root)
|
||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||
|
||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||
viewer_static = strix_root / 'interface' / 'viewer' / 'static'
|
||||
for asset in viewer_static.rglob('*'):
|
||||
if asset.is_file():
|
||||
rel_path = asset.relative_to(project_root)
|
||||
datas.append((str(asset), str(rel_path.parent)))
|
||||
|
||||
datas += collect_data_files('textual')
|
||||
|
||||
datas += collect_data_files('tiktoken')
|
||||
@@ -158,21 +151,6 @@ hiddenimports = [
|
||||
'strix.report.dedupe',
|
||||
'strix.report.state',
|
||||
'strix.report.writer',
|
||||
'strix.interface.viewer',
|
||||
'strix.interface.viewer.auth',
|
||||
'strix.interface.viewer.cli',
|
||||
'strix.interface.viewer.report_pdf',
|
||||
'strix.interface.viewer.server',
|
||||
'strix.interface.viewer.transcript',
|
||||
|
||||
# PDF report generation + encryption
|
||||
'reportlab',
|
||||
'reportlab.pdfgen',
|
||||
'reportlab.pdfbase',
|
||||
'reportlab.lib',
|
||||
'reportlab.platypus',
|
||||
'pypdf',
|
||||
'cryptography',
|
||||
'strix.runtime',
|
||||
'strix.runtime.backends',
|
||||
'strix.runtime.caido_bootstrap',
|
||||
@@ -200,16 +178,6 @@ hiddenimports += collect_submodules('textual')
|
||||
hiddenimports += collect_submodules('rich')
|
||||
hiddenimports += collect_submodules('pydantic')
|
||||
hiddenimports += collect_submodules('pygments')
|
||||
# reportlab loads renderers/fonts dynamically, so pull its whole tree in.
|
||||
hiddenimports += collect_submodules('reportlab')
|
||||
|
||||
# reportlab ships bundled fonts (.pfb/.afm) it needs at runtime.
|
||||
datas += collect_data_files('reportlab')
|
||||
|
||||
# reportlab imports PIL (pillow) lazily for image handling, so it must be
|
||||
# bundled explicitly and kept out of the excludes list below.
|
||||
hiddenimports += collect_submodules('PIL')
|
||||
datas += collect_data_files('PIL')
|
||||
|
||||
excludes = [
|
||||
# Sandbox-only packages
|
||||
@@ -257,6 +225,7 @@ excludes = [
|
||||
'numpy',
|
||||
'pandas',
|
||||
'scipy',
|
||||
'PIL',
|
||||
'cv2',
|
||||
]
|
||||
|
||||
|
||||
+23
-161
@@ -16,7 +16,6 @@ from agents.tool import CustomTool, FunctionTool, Tool
|
||||
from pydantic import ValidationError
|
||||
|
||||
from strix.agents.prompt import render_system_prompt
|
||||
from strix.config import load_settings
|
||||
from strix.tools.agents_graph.tools import (
|
||||
agent_finish,
|
||||
create_agent,
|
||||
@@ -34,7 +33,6 @@ from strix.tools.notes.tools import (
|
||||
list_notes,
|
||||
update_note,
|
||||
)
|
||||
from strix.tools.output_store import bound_and_store, bound_text
|
||||
from strix.tools.proxy.tools import (
|
||||
list_requests,
|
||||
list_sitemap,
|
||||
@@ -43,12 +41,7 @@ from strix.tools.proxy.tools import (
|
||||
view_request,
|
||||
view_sitemap_entry,
|
||||
)
|
||||
from strix.tools.reporting.tool import (
|
||||
create_dependency_report,
|
||||
create_vulnerability_report,
|
||||
get_report,
|
||||
list_reports,
|
||||
)
|
||||
from strix.tools.reporting.tool import create_vulnerability_report
|
||||
from strix.tools.thinking.tool import think
|
||||
from strix.tools.todo.tools import (
|
||||
create_todo,
|
||||
@@ -62,7 +55,7 @@ from strix.tools.web_search.tool import web_search
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from agents import RunContextWrapper
|
||||
from agents.tool import FunctionToolResult
|
||||
@@ -110,36 +103,8 @@ def _extract_custom_input(tool: CustomTool, raw_input: str | dict[str, Any]) ->
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _tool_output_limits() -> tuple[int, int]:
|
||||
context = load_settings().context
|
||||
return context.tool_output_max_lines, context.tool_output_max_bytes
|
||||
|
||||
|
||||
async def _bound_result(result: Any) -> Any:
|
||||
if not isinstance(result, str):
|
||||
return result
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return await bound_and_store(result, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _format_tool_error(exc: Exception) -> str:
|
||||
message = str(exc) or exc.__class__.__name__
|
||||
max_lines, max_bytes = _tool_output_limits()
|
||||
return bound_text(message, max_lines=max_lines, max_bytes=max_bytes)
|
||||
|
||||
|
||||
def _with_bounded_result(tool: FunctionTool) -> FunctionTool:
|
||||
"""Cap a tool's result size before it enters history (idempotent)."""
|
||||
if getattr(tool, "_strix_bounded", False):
|
||||
return tool
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
tool._strix_bounded = True # type: ignore[attr-defined]
|
||||
return tool
|
||||
return str(exc) or exc.__class__.__name__
|
||||
|
||||
|
||||
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||
@@ -147,7 +112,7 @@ def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
try:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
except Exception as exc: # noqa: BLE001 - tool errors should be model-visible results.
|
||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||
return _format_tool_error(exc)
|
||||
@@ -162,7 +127,7 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
||||
if not custom_input:
|
||||
return f"`{_custom_tool_input_field(tool)}` must be a non-empty string."
|
||||
try:
|
||||
return await _bound_result(await tool.on_invoke_tool(ctx, custom_input))
|
||||
return await tool.on_invoke_tool(ctx, custom_input)
|
||||
except Exception as exc: # noqa: BLE001 - matches SDK CustomTool error-as-result behavior.
|
||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||
return _format_tool_error(exc)
|
||||
@@ -194,35 +159,12 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
||||
)
|
||||
|
||||
|
||||
def _bound_custom_tool(tool: CustomTool) -> CustomTool:
|
||||
"""Bound a native ``CustomTool`` result in place (Responses path)."""
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||
|
||||
tool.on_invoke_tool = invoke
|
||||
return tool
|
||||
|
||||
|
||||
def _configure_filesystem_tools(toolset: Any, *, chat_completions: bool) -> None:
|
||||
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
|
||||
for name, tool in vars(toolset).items():
|
||||
if chat_completions:
|
||||
if isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||
elif isinstance(tool, FunctionTool):
|
||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
||||
elif isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _bound_custom_tool(tool))
|
||||
if isinstance(tool, CustomTool):
|
||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||
elif isinstance(tool, FunctionTool):
|
||||
setattr(toolset, name, _with_bounded_result(tool))
|
||||
|
||||
|
||||
def _make_filesystem_configurator(*, chat_completions: bool) -> Any:
|
||||
def configure(toolset: Any) -> None:
|
||||
_configure_filesystem_tools(toolset, chat_completions=chat_completions)
|
||||
|
||||
return configure
|
||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
||||
|
||||
|
||||
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
|
||||
@@ -263,29 +205,10 @@ def _format_validation_error(tool_name: str, exc: ValidationError) -> str:
|
||||
return f"{tool_name}: invalid arguments — " + "; ".join(parts)
|
||||
|
||||
|
||||
def _apply_shell_output_cap(parsed: dict[str, Any]) -> None:
|
||||
"""Clamp the SDK shell tools' ``max_output_tokens`` to the configured
|
||||
ceiling; a smaller explicit value is respected."""
|
||||
ceiling = load_settings().context.tool_output_max_tokens
|
||||
requested = parsed.get("max_output_tokens")
|
||||
parsed["max_output_tokens"] = (
|
||||
ceiling if not isinstance(requested, int) or requested > ceiling else requested
|
||||
)
|
||||
|
||||
|
||||
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
||||
invoke_tool = tool.on_invoke_tool
|
||||
|
||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||
try:
|
||||
parsed = json.loads(raw_input)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
if "shell" not in parsed:
|
||||
parsed["shell"] = "bash"
|
||||
_apply_shell_output_cap(parsed)
|
||||
raw_input = json.dumps(parsed)
|
||||
try:
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
except ValidationError as exc:
|
||||
@@ -310,10 +233,8 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
|
||||
parsed = json.loads(raw_input)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
if isinstance(parsed.get("chars"), str):
|
||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||
_apply_shell_output_cap(parsed)
|
||||
if isinstance(parsed, dict) and isinstance(parsed.get("chars"), str):
|
||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||
raw_input = json.dumps(parsed)
|
||||
try:
|
||||
return await invoke_tool(ctx, raw_input)
|
||||
@@ -414,9 +335,6 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
||||
delete_note,
|
||||
web_search,
|
||||
create_vulnerability_report,
|
||||
create_dependency_report,
|
||||
list_reports,
|
||||
get_report,
|
||||
list_requests,
|
||||
view_request,
|
||||
repeat_request,
|
||||
@@ -431,48 +349,6 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
||||
)
|
||||
|
||||
|
||||
# Extra tools registered for scan agents. Mirrors
|
||||
# ``strix.runtime.backends.register_backend``: register before the first
|
||||
# ``build_strix_agent`` call and every agent (root + children) gets them.
|
||||
_EXTRA_TOOLS: list[Tool] = []
|
||||
|
||||
|
||||
def _ensure_unique_tool_names(tools: Sequence[Tool]) -> None:
|
||||
seen: set[str] = set()
|
||||
duplicates: set[str] = set()
|
||||
for tool in tools:
|
||||
if tool.name in seen:
|
||||
duplicates.add(tool.name)
|
||||
seen.add(tool.name)
|
||||
if duplicates:
|
||||
msg = f"Agent tools must have unique names: {sorted(duplicates)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def register_agent_tools(*tools: Tool) -> None:
|
||||
"""Register tools for every scan agent built afterwards.
|
||||
|
||||
Tools are added to both root and child agents, after the base set and
|
||||
before the lifecycle tool (``finish_scan`` / ``agent_finish``). Duplicate
|
||||
tool objects are ignored so repeated imports don't double-register.
|
||||
"""
|
||||
new_tools: list[Tool] = []
|
||||
for tool in tools:
|
||||
if tool not in _EXTRA_TOOLS and tool not in new_tools:
|
||||
new_tools.append(tool)
|
||||
|
||||
_ensure_unique_tool_names([*_BASE_TOOLS, *_EXTRA_TOOLS, *new_tools, finish_scan, agent_finish])
|
||||
|
||||
for tool in new_tools:
|
||||
_EXTRA_TOOLS.append(tool)
|
||||
logger.info("Registered extra agent tool: %s", getattr(tool, "name", tool))
|
||||
|
||||
|
||||
def registered_agent_tools() -> tuple[Tool, ...]:
|
||||
"""Return the currently registered scan-agent tools."""
|
||||
return tuple(_EXTRA_TOOLS)
|
||||
|
||||
|
||||
def build_strix_agent(
|
||||
*,
|
||||
name: str = "strix",
|
||||
@@ -483,40 +359,26 @@ def build_strix_agent(
|
||||
interactive: bool = False,
|
||||
chat_completions_tools: bool = False,
|
||||
system_prompt_context: dict[str, Any] | None = None,
|
||||
extra_tools: Sequence[Tool] | None = None,
|
||||
instructions_override: str | None = None,
|
||||
) -> SandboxAgent[Any]:
|
||||
"""Build a SandboxAgent for either root or child use.
|
||||
|
||||
Args:
|
||||
chat_completions_tools: Wrap SDK custom tools as function tools
|
||||
when the selected backend cannot accept Responses custom tools.
|
||||
extra_tools: Additional tools for this scan agent only, on top of any
|
||||
registered via ``register_agent_tools``.
|
||||
instructions_override: Use this verbatim as the system prompt instead
|
||||
of rendering the built-in scan prompt.
|
||||
"""
|
||||
if instructions_override is not None:
|
||||
instructions = instructions_override
|
||||
else:
|
||||
instructions = render_system_prompt(
|
||||
skills=skills,
|
||||
scan_mode=scan_mode,
|
||||
is_whitebox=is_whitebox,
|
||||
is_root=is_root,
|
||||
interactive=interactive,
|
||||
system_prompt_context=system_prompt_context,
|
||||
)
|
||||
instructions = render_system_prompt(
|
||||
skills=skills,
|
||||
scan_mode=scan_mode,
|
||||
is_whitebox=is_whitebox,
|
||||
is_root=is_root,
|
||||
interactive=interactive,
|
||||
system_prompt_context=system_prompt_context,
|
||||
)
|
||||
|
||||
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
|
||||
if is_root:
|
||||
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
|
||||
tools: list[Tool] = [*_BASE_TOOLS, finish_scan]
|
||||
else:
|
||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||
_ensure_unique_tool_names(tools)
|
||||
tools = [
|
||||
_with_bounded_result(tool) if isinstance(tool, FunctionTool) else tool for tool in tools
|
||||
]
|
||||
tools = [*_BASE_TOOLS, agent_finish]
|
||||
|
||||
logger.info(
|
||||
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
|
||||
@@ -536,8 +398,8 @@ def build_strix_agent(
|
||||
model=None,
|
||||
capabilities=[
|
||||
Filesystem(
|
||||
configure_tools=_make_filesystem_configurator(
|
||||
chat_completions=chat_completions_tools,
|
||||
configure_tools=(
|
||||
_configure_chat_completions_filesystem_tools if chat_completions_tools else None
|
||||
),
|
||||
),
|
||||
Shell(
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from strix.skills import get_available_skills, load_skills, skill_search_dirs
|
||||
from strix.skills import get_available_skills, load_skills
|
||||
from strix.utils.resource_paths import get_strix_resource_path
|
||||
|
||||
|
||||
@@ -69,9 +69,9 @@ def render_system_prompt(
|
||||
"""Render the system prompt. Returns empty string on template failure."""
|
||||
try:
|
||||
prompt_dir = get_strix_resource_path("agents", _PROMPT_DIRNAME)
|
||||
loader_dirs = [prompt_dir, *skill_search_dirs()]
|
||||
skills_dir = get_strix_resource_path("skills")
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(loader_dirs),
|
||||
loader=FileSystemLoader([prompt_dir, skills_dir]),
|
||||
autoescape=select_autoescape(
|
||||
enabled_extensions=(),
|
||||
default_for_string=False,
|
||||
@@ -91,7 +91,6 @@ def render_system_prompt(
|
||||
loaded_skill_names=list(skill_content.keys()),
|
||||
available_skills=get_available_skills(),
|
||||
interactive=interactive,
|
||||
is_root=is_root,
|
||||
system_prompt_context=system_prompt_context or {},
|
||||
**skill_content,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
You are Strix, an advanced AI application security validation agent developed by OmniSecure Labs. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues.
|
||||
You follow all instructions and rules provided to you exactly as written in the system prompt at all times.
|
||||
{% if is_root %}
|
||||
<root_agent_directive>
|
||||
YOU ARE THE ROOT AGENT. Your job is ORCHESTRATION, not hands-on testing.
|
||||
- You accomplish security work by DELEGATING to specialized subagents via create_agent — you do NOT run scanners, crawlers, fuzzers, or send exploit/injection payloads yourself.
|
||||
- IMPORTANT — how to read this prompt as root: the rest of this system prompt is written in the second person ("you") and describes the hands-on testing methodology (recon, mapping, scanning, payload spraying, PoC building, fixing). When you are the root agent, treat every such hands-on instruction as something you ensure gets done BY A SUBAGENT, not as a task you perform in your own turns. The "map the target", "recon first", "mandatory initial phases", and "spray payloads" directives are DELEGATION REQUIREMENTS for you — spawn recon/mapping/testing subagents to satisfy them.
|
||||
- Do NOT probe endpoints, run "basic" or "quick" injection/XSS/etc. tests, or do exploratory scanning before delegating. Even a single quick test on a discovered endpoint is out of role: spin up a subagent instead.
|
||||
- Your own turns should be spent on: reading scope/config, decomposing the target, spawning and monitoring subagents, tracking todos/notes/coverage, deciding next steps, and aggregating results into the final report.
|
||||
</root_agent_directive>
|
||||
{% endif %}
|
||||
|
||||
<core_capabilities>
|
||||
- Security assessment and vulnerability scanning
|
||||
@@ -134,8 +125,10 @@ WHITE-BOX TESTING (code provided):
|
||||
- Local execution, unit/integration testing, patch verification, and HTTP requests against locally started in-scope services are normal authorized white-box validation
|
||||
- If dynamically running the code proves impossible after exhaustive attempts, pivot to comprehensive static analysis.
|
||||
- Try to infer how to run the code based on its structure and content.
|
||||
- Derive the code fix as PART OF reporting, not as a separate later pass: create_vulnerability_report already requires the concrete patch inline (`code_locations` with verbatim `fix_before`/`fix_after` and `fix_pr_body`), so the reporting agent that analyzes the root cause is the one that produces the fix. Do NOT spawn a downstream agent afterwards to re-derive/re-apply the same patch.
|
||||
- If you also apply and verify the patch in the repo (edit the file, re-test that the vulnerability is gone), do it in the same agent/turn while the analysis is fresh — right before or as part of filing the report — never as a second re-analysis pass.
|
||||
- FIX discovered vulnerabilities in code in same file.
|
||||
- Test patches to confirm vulnerability removal.
|
||||
- Do not stop until all reported vulnerabilities are fixed.
|
||||
- Include code diff in final report.
|
||||
|
||||
COMBINED MODE (code + deployed target present):
|
||||
- Treat this as static analysis plus dynamic testing simultaneously
|
||||
@@ -175,28 +168,13 @@ EFFICIENCY TACTICS:
|
||||
- Download additional tools as needed for specific tasks
|
||||
- Run multiple scans in parallel when possible
|
||||
- Load the most relevant skill before starting a specialized testing workflow if doing so will improve accuracy, speed, or tool usage
|
||||
- Use `exec_command` for Python code: write reusable scripts to a file and
|
||||
run them with `python3 script.py`. For one-off snippets, `python3 -c` or a
|
||||
here-document is acceptable, but avoid deeply nested quotes/parentheses — if
|
||||
a snippet needs complex quoting or is more than a few lines, write it to a
|
||||
file first to prevent syntax errors.
|
||||
- Before importing a third-party Python library, make sure it is installed. The
|
||||
sandbox's `python3` runs inside a preconfigured virtualenv that ships
|
||||
`requests`, `httpx`, `beautifulsoup4` (bs4), `lxml`, `pyjwt`, and
|
||||
`cryptography`; for anything else prefer the stdlib or run `pip install <pkg>`
|
||||
(it installs into that active venv) before importing, rather than letting the
|
||||
script fail with `ModuleNotFoundError`.
|
||||
- `exec_command` runs each command in a fresh non-interactive shell (plain
|
||||
pipes, no TTY). To drive an interactive or long-running process with
|
||||
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `sqlmap`, or to send Ctrl-C —
|
||||
you MUST start it with `exec_command(cmd="...", tty=true)` and then
|
||||
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
|
||||
default (non-TTY) command or on a process that has already exited fails with
|
||||
"stdin is not available".
|
||||
- Use `exec_command` for Python code: write reusable scripts under
|
||||
`/workspace/scratch/` and run them with `python3`. For one-off snippets,
|
||||
`python3 -c` or a here-document is acceptable.
|
||||
- For Caido proxy automation inside Python, explicitly import from
|
||||
`caido_api`:
|
||||
`from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules`
|
||||
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
|
||||
- Prefer established fuzzers/scanners where applicable: ffuf, sqlmap, zaproxy, nuclei, wapiti, arjun, httpx, katana, semgrep, bandit, trufflehog, nmap. Use scripts mainly to coordinate or validate around them, not to replace them without reason
|
||||
- For trial-heavy vectors (SQLi, XSS, XXE, SSRF, RCE, auth/JWT, deserialization), DO NOT iterate payloads manually in the browser. Always spray payloads via Python scripts through `exec_command` or terminal tools.
|
||||
- When using established fuzzers/scanners, use the proxy for inspection where helpful
|
||||
- Generate/adapt large payload corpora: combine encodings (URL, unicode, base64), comment styles, wrappers, time-based/differential probes. Expand with wordlists/templates
|
||||
@@ -208,14 +186,13 @@ EFFICIENCY TACTICS:
|
||||
VALIDATION REQUIREMENTS:
|
||||
- Full validation required - no assumptions
|
||||
- Demonstrate concrete impact with evidence
|
||||
- Consider business context for severity assessment — check whether the target is a demo/sandbox environment or content meant to be public, and factor that in
|
||||
- Consider business context for severity assessment
|
||||
- Independent verification through subagent
|
||||
- Document complete attack chain
|
||||
- Keep going until you find something that matters
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||
- Do NOT patch/fix before reporting: first create the vulnerability report via create_vulnerability_report (by the reporting agent). Only after reporting is completed should fixing/patching proceed
|
||||
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
|
||||
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
|
||||
</execution_guidelines>
|
||||
|
||||
<vulnerability_focus>
|
||||
@@ -263,20 +240,12 @@ AGENT ISOLATION & SANDBOXING:
|
||||
- All agents share the same /workspace directory and proxy history
|
||||
- Agents can see each other's files and proxy traffic for better collaboration
|
||||
|
||||
DISK & SCRATCH HYGIENE:
|
||||
- /workspace is a shared, finite disk used by all agents at once — be a considerate tenant
|
||||
- Prefer bounded recon: scope crawls and scans by depth, duration, and target rather than "collect everything"
|
||||
- Redirect large tool output to a file, and once you've extracted what you need (e.g. a URL/endpoint list), remove the raw output
|
||||
- If disk gets tight or a write fails for space, check what's large under /workspace and clean up files from your own task; leave another agent's files unless you've confirmed they're no longer in use
|
||||
|
||||
MANDATORY INITIAL PHASES:
|
||||
{% if is_root %}
|
||||
- ROOT AGENT: these phases are mandatory for the assessment, but you MUST accomplish them by delegating to reconnaissance/mapping subagents — do NOT run recon, crawling, enumeration, or mapping tools in your own turns. Spawn the appropriate subagent(s) and track their coverage.
|
||||
{% endif %}
|
||||
|
||||
BLACK-BOX TESTING - PHASE 1 (RECON & MAPPING):
|
||||
- COMPLETE full reconnaissance: subdomain enumeration, port scanning, service detection
|
||||
- MAP entire attack surface: all endpoints, parameters, APIs, forms, inputs
|
||||
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files — keep each crawl bounded by depth/duration, and tidy up raw output once endpoints are extracted
|
||||
- CRAWL thoroughly: spider all pages (authenticated and unauthenticated), discover hidden paths, analyze JS files
|
||||
- ENUMERATE technologies: frameworks, libraries, versions, dependencies
|
||||
- Reconnaissance should normally happen before targeted vulnerability discovery unless the correct next move is already obvious or the user/system explicitly asks to prioritize a specific area first
|
||||
- ONLY AFTER comprehensive mapping → proceed to vulnerability testing
|
||||
@@ -301,14 +270,13 @@ ROOT AGENT ROLE:
|
||||
- The root agent should coordinate strategy, delegate meaningful work, track progress, maintain todo lists, maintain notes, monitor subagent results, and decide next steps
|
||||
- The root agent should keep a clear view of overall coverage, uncovered attack surfaces, validation status, and reporting/fixing progress
|
||||
- The root agent should avoid spending its own iterations on detailed testing, payload execution, or deep target-specific investigation when that work can be delegated to specialized subagents
|
||||
- The root agent may do orchestration-support work needed to delegate well — reading scope/config, inspecting workspace layout, reading subagent output/reports, and light bookkeeping. It must NOT do the actual security testing itself: no running scanners/fuzzers/crawlers, no sending injection/XSS/SSRF/etc. payloads, and no "basic" or "quick" probing of discovered endpoints. If a check requires touching the target, delegate it to a subagent rather than doing it yourself
|
||||
- Its default and near-exclusive mode is coordinator/controller
|
||||
- The root agent may do lightweight triage, quick verification, or setup work when necessary to unblock delegation, but its default mode should be coordinator/controller
|
||||
- Subagents should do the substantive testing, validation, reporting, and fixing work
|
||||
- The root agent is responsible for ensuring that work is broken down clearly, tracked, and completed across the agent tree
|
||||
|
||||
1. **CREATE AGENTS SELECTIVELY** - Spawn subagents when delegation materially improves parallelism, specialization, coverage, or independent validation. Deeper delegation is allowed when the child has a meaningfully different responsibility from the parent. Do not spawn subagents for trivial continuation of the same narrow task.
|
||||
2. **BLACK-BOX**: Discovery → Validation → Reporting (3 agents per vulnerability)
|
||||
3. **WHITE-BOX**: Discovery → Validation → Reporting-with-fix (3 agents per vulnerability — the reporting agent derives and files the fix inline; do NOT add a separate fixing agent that re-derives the same patch)
|
||||
3. **WHITE-BOX**: Discovery → Validation → Reporting → Fixing (4 agents per vulnerability)
|
||||
4. **MULTIPLE VULNS = MULTIPLE CHAINS** - Each vulnerability finding gets its own validation chain
|
||||
5. **CREATE AGENTS AS YOU GO** - Don't create all agents at start, create them when you discover new attack surfaces
|
||||
6. **ONE JOB PER AGENT** - Each agent has ONE specific task only
|
||||
@@ -327,7 +295,8 @@ BLACK-BOX (domain/URL only):
|
||||
WHITE-BOX (source code provided):
|
||||
- Found authentication code issues? → Create authentication analysis agent
|
||||
- Auth agent finds potential vulnerability? → Create "Auth Validation Agent"
|
||||
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent" that files the report AND its inline fix (`code_locations` + `fix_pr_body`) in one shot — no separate fixing agent
|
||||
- Validation agent confirms vulnerability? → Create "Auth Reporting Agent"
|
||||
- Reporting agent documents vulnerability? → Create "Auth Fixing Agent" (implement code fix and test it works)
|
||||
|
||||
VULNERABILITY WORKFLOW (MANDATORY FOR EVERY FINDING):
|
||||
|
||||
@@ -348,11 +317,9 @@ Authentication Code Agent finds weak password validation
|
||||
↓
|
||||
Spawns "Auth Validation Agent" (proves it's exploitable)
|
||||
↓
|
||||
If valid → Spawns "Auth Reporting Agent" (creates the vulnerability report
|
||||
WITH the fix inline: code_locations fix_before/fix_after + fix_pr_body,
|
||||
applying/verifying the patch in the same turn if desired)
|
||||
If valid → Spawns "Auth Reporting Agent" (creates vulnerability report)
|
||||
↓
|
||||
STOP - no separate fixing agent; the fix was derived once, at report time
|
||||
Spawns "Auth Fixing Agent" (implements secure code fix)
|
||||
```
|
||||
|
||||
CRITICAL RULES:
|
||||
@@ -388,7 +355,7 @@ FOCUS PRINCIPLES:
|
||||
REALISTIC TESTING OUTCOMES:
|
||||
- **No Findings**: Agent completes testing but finds no vulnerabilities
|
||||
- **Validation Failed**: Initial finding was false positive, validation agent confirms it's not exploitable
|
||||
- **Valid Vulnerability**: Validation succeeds, spawns a reporting agent that files the report with the fix inline (white-box) — no separate fixing agent
|
||||
- **Valid Vulnerability**: Validation succeeds, spawns reporting agent and then fixing agent (white-box)
|
||||
|
||||
PERSISTENCE IS MANDATORY:
|
||||
- Real vulnerabilities take TIME - expect to need 2000+ steps minimum
|
||||
@@ -413,6 +380,7 @@ VULNERABILITY ASSESSMENT:
|
||||
- nuclei - Vulnerability scanner with templates
|
||||
- sqlmap - SQL injection detection/exploitation
|
||||
- trivy - Container/dependency vulnerability scanner
|
||||
- zaproxy - OWASP ZAP web app scanner
|
||||
- wapiti - Web vulnerability scanner
|
||||
|
||||
WEB FUZZING & DISCOVERY:
|
||||
@@ -445,15 +413,14 @@ SPECIALIZED TOOLS:
|
||||
PROXY & INTERCEPTION:
|
||||
- Caido CLI - Modern web proxy (already running). Use the proxy tools
|
||||
directly, or import `caido_api` from sandbox Python scripts.
|
||||
- HTTPQL filters (for `list_requests`): quote string values, leave integers unquoted (`resp.code.eq:200`, not `"200"`); combine terms with `AND`/`OR` (there is no `NOT` — use the negated operator `ne`/`ncont`/`nregex`). Numeric fields (`resp.code`, `req.port`) use `eq`/`ne`/`gt`/`gte`/`lt`/`lte`; text fields (`req.host`, `req.path`, `req.method`, `req.raw`) use `cont`/`ncont`/`eq`/`regex`. Example: `resp.code.gte:200 AND resp.code.lt:300 AND req.host.cont:"api"`.
|
||||
- NOTE: If you are seeing proxy errors when sending requests, it usually means you are not sending requests to a correct url/host/port.
|
||||
- Ignore Caido proxy-generated 50x HTML error pages; these are proxy issues (might happen when requesting a wrong host or SSL/TLS issues, etc).
|
||||
|
||||
PROGRAMMING:
|
||||
- Python 3, uv, Node.js/npm
|
||||
- Python 3, uv, Go, Node.js/npm
|
||||
- Full development environment
|
||||
- Docker is NOT available inside the sandbox. Do not run docker; rely on provided tools to run locally.
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, etc.)
|
||||
- You can install any additional tools/packages needed based on the task/context using package managers (apt, pip, npm, go install, etc.)
|
||||
|
||||
Directories:
|
||||
- /workspace - where you should work.
|
||||
|
||||
@@ -17,8 +17,6 @@ from strix.config.loader import (
|
||||
persist_current,
|
||||
)
|
||||
from strix.config.settings import (
|
||||
ContextSettings,
|
||||
DedupeSettings,
|
||||
IntegrationSettings,
|
||||
LlmSettings,
|
||||
RuntimeSettings,
|
||||
@@ -28,8 +26,6 @@ from strix.config.settings import (
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ContextSettings",
|
||||
"DedupeSettings",
|
||||
"IntegrationSettings",
|
||||
"LlmSettings",
|
||||
"RuntimeSettings",
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
"""ChatGPT (Codex) subscription auth: OAuth login, token refresh, and the OpenAI
|
||||
client that routes inference through the ChatGPT backend.
|
||||
|
||||
Mirrors OpenAI's Codex CLI: OAuth 2.0 + PKCE against ``auth.openai.com``, with the
|
||||
access token sent as a ``Bearer`` token to ``chatgpt.com/backend-api/codex``. Using
|
||||
a ChatGPT subscription outside OpenAI's own products is not officially supported by
|
||||
OpenAI; the user chooses this path knowingly. The OAuth constants are OpenAI's own
|
||||
Codex CLI values (the backend only accepts that client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PROVIDER = "codex"
|
||||
|
||||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
||||
TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 # nosec B105 - URL, not a secret
|
||||
CALLBACK_HOST = "localhost"
|
||||
CALLBACK_PORT = 1455
|
||||
CALLBACK_PATH = "/auth/callback"
|
||||
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
|
||||
SCOPE = "openid profile email offline_access"
|
||||
|
||||
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
ORIGINATOR = "codex_cli_rs"
|
||||
_ACCOUNT_CLAIM = "https://api.openai.com/auth"
|
||||
|
||||
_TOKEN_TIMEOUT = 30
|
||||
_EXPIRY_SKEW_S = 300
|
||||
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
# Kept separate from cli-config.json so OAuth tokens never land in the env-var config.
|
||||
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
|
||||
|
||||
|
||||
def _read_store() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_store(data: dict[str, Any]) -> None:
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
record = _read_store().get(PROVIDER)
|
||||
if not isinstance(record, dict) or record.get("type") != "oauth":
|
||||
return None
|
||||
if not (record.get("access") and record.get("refresh") and record.get("account_id")):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
return read_record() is not None
|
||||
|
||||
|
||||
def save_record(record: dict[str, Any]) -> None:
|
||||
data = _read_store()
|
||||
data[PROVIDER] = record
|
||||
_write_store(data)
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
data = _read_store()
|
||||
if PROVIDER not in data:
|
||||
return
|
||||
del data[PROVIDER]
|
||||
if data:
|
||||
_write_store(data)
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _refresh_guard() -> Iterator[None]:
|
||||
"""Serialize token refresh within (lock) and across (flock) Strix processes,
|
||||
so concurrent runs can't both spend the single-use refresh token."""
|
||||
with _refresh_lock:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
lock_path = AUTH_PATH.with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = lock_path.open("w")
|
||||
except (ImportError, OSError):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
|
||||
|
||||
class CodexAuthError(Exception):
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
class CodexContentGuardrailError(Exception):
|
||||
"""The ChatGPT backend refused a request via its content guardrail.
|
||||
Terminal — retrying identical content never clears the block."""
|
||||
|
||||
def __init__(self, model: str, original: BaseException | None = None) -> None:
|
||||
self.model = model
|
||||
self.original = original
|
||||
super().__init__(
|
||||
f"'{model}' was blocked by ChatGPT's content guardrails "
|
||||
f"(flagged as a possible cybersecurity risk). "
|
||||
f"Set STRIX_LLM to a model that isn't blocked and re-run."
|
||||
)
|
||||
|
||||
|
||||
_GUARDRAIL_MARKERS = (
|
||||
"flagged for possible cybersecurity risk",
|
||||
"trusted access for cyber",
|
||||
)
|
||||
|
||||
|
||||
def is_content_guardrail_error(exc: BaseException) -> bool:
|
||||
if isinstance(exc, CodexContentGuardrailError):
|
||||
return True
|
||||
text = str(exc).lower()
|
||||
return any(marker in text for marker in _GUARDRAIL_MARKERS)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str]:
|
||||
verifier = _b64url(secrets.token_bytes(64))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def create_state() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def build_authorize_url(challenge: str, state: str) -> str:
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
"originator": ORIGINATOR,
|
||||
}
|
||||
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
|
||||
def parse_redirect_input(value: str) -> tuple[str | None, str | None]:
|
||||
"""Extract ``(code, state)`` from a pasted redirect URL, ``code#state``,
|
||||
query string, or bare code."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None, None
|
||||
with contextlib.suppress(ValueError):
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme and parsed.query:
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
if "#" in value:
|
||||
code, _, state = value.partition("#")
|
||||
return code or None, state or None
|
||||
if "code=" in value:
|
||||
query = urllib.parse.parse_qs(value)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
return value, None
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
|
||||
try:
|
||||
response = requests.post(
|
||||
TOKEN_URL,
|
||||
data=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise CodexAuthError("unavailable", str(exc)) from exc
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:300]
|
||||
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
|
||||
data = json.loads(response.content or b"{}")
|
||||
if not isinstance(data, dict):
|
||||
raise CodexAuthError("bad_response", "token endpoint returned non-object")
|
||||
return data
|
||||
|
||||
|
||||
def _record_from_token_response(
|
||||
data: dict[str, Any], refresh_fallback: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
access = data.get("access_token")
|
||||
# A refresh response may omit refresh_token when it isn't rotated; keep the old one.
|
||||
refresh = data.get("refresh_token") or refresh_fallback
|
||||
expires_in = data.get("expires_in")
|
||||
if not isinstance(access, str) or not access:
|
||||
raise CodexAuthError("bad_response", "token response missing access_token")
|
||||
if not isinstance(refresh, str) or not refresh:
|
||||
raise CodexAuthError("bad_response", "token response missing refresh_token")
|
||||
account_id = _account_id_from_jwt(access) or _account_id_from_jwt(
|
||||
data.get("id_token") if isinstance(data.get("id_token"), str) else ""
|
||||
)
|
||||
if not account_id:
|
||||
raise CodexAuthError("no_account_id", "could not read chatgpt_account_id from token")
|
||||
ttl = expires_in if isinstance(expires_in, int | float) else 3600
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": PROVIDER,
|
||||
"access": access,
|
||||
"refresh": refresh,
|
||||
"account_id": account_id,
|
||||
"expires_at": time.time() + ttl,
|
||||
}
|
||||
|
||||
|
||||
def exchange_code(code: str, verifier: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data, refresh_fallback=refresh_token)
|
||||
|
||||
|
||||
def _account_id_from_jwt(token: str | None) -> str | None:
|
||||
"""Read the account id claim without verifying the JWT (the server enforces
|
||||
authenticity on use); it feeds the ``chatgpt-account-id`` header."""
|
||||
if not token or token.count(".") != 2:
|
||||
return None
|
||||
payload_b64 = token.split(".")[1]
|
||||
padding = "=" * (-len(payload_b64) % 4)
|
||||
try:
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
auth = payload.get(_ACCOUNT_CLAIM)
|
||||
if isinstance(auth, dict):
|
||||
account_id = auth.get("chatgpt_account_id")
|
||||
if isinstance(account_id, str) and account_id:
|
||||
return account_id
|
||||
organizations = payload.get("organizations")
|
||||
if isinstance(organizations, list) and organizations and isinstance(organizations[0], dict):
|
||||
org_id = organizations[0].get("id")
|
||||
if isinstance(org_id, str) and org_id:
|
||||
return org_id
|
||||
return None
|
||||
|
||||
|
||||
def _near_expiry(record: dict[str, Any]) -> bool:
|
||||
expires_at = record.get("expires_at")
|
||||
if not isinstance(expires_at, int | float):
|
||||
return True
|
||||
return expires_at - _EXPIRY_SKEW_S <= time.time()
|
||||
|
||||
|
||||
def get_valid_token() -> tuple[str, str]:
|
||||
"""Return ``(access_token, account_id)``, refreshing under the cross-process
|
||||
guard if near expiry."""
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
with _refresh_guard():
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||
if not _near_expiry(record):
|
||||
return record["access"], record["account_id"]
|
||||
try:
|
||||
refreshed = refresh_tokens(record["refresh"])
|
||||
except CodexAuthError:
|
||||
# A peer process may have already spent this single-use refresh token.
|
||||
latest = read_record()
|
||||
if latest and latest["refresh"] != record["refresh"] and not _near_expiry(latest):
|
||||
return latest["access"], latest["account_id"]
|
||||
raise
|
||||
save_record(refreshed)
|
||||
return refreshed["access"], refreshed["account_id"]
|
||||
|
||||
|
||||
def build_openai_client() -> AsyncOpenAI:
|
||||
"""An ``AsyncOpenAI`` for the ChatGPT backend. A per-request hook re-stamps a
|
||||
fresh bearer token so long scans survive token expiry."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
get_valid_token() # fail fast at configure time if the sign-in is dead
|
||||
|
||||
async def _auth_hook(request: httpx.Request) -> None:
|
||||
access, account_id = await asyncio.to_thread(get_valid_token)
|
||||
request.headers["Authorization"] = f"Bearer {access}"
|
||||
request.headers["chatgpt-account-id"] = account_id
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||
event_hooks={"request": [_auth_hook]},
|
||||
)
|
||||
return AsyncOpenAI(
|
||||
api_key="strix-codex-oauth", # placeholder; the hook overwrites Authorization
|
||||
base_url=CODEX_BASE_URL,
|
||||
http_client=http_client,
|
||||
default_headers={
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": ORIGINATOR,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_subscription_client: AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def get_subscription_client() -> AsyncOpenAI:
|
||||
global _subscription_client # noqa: PLW0603
|
||||
if _subscription_client is None:
|
||||
_subscription_client = build_openai_client()
|
||||
return _subscription_client
|
||||
|
||||
|
||||
SUBSCRIPTION_PREFIX = "chatgpt/"
|
||||
|
||||
|
||||
def subscription_model(model_name: str | None) -> str | None:
|
||||
"""The model slug behind a ``chatgpt/<model>`` STRIX_LLM, or None."""
|
||||
name = (model_name or "").strip()
|
||||
if not name.lower().startswith(SUBSCRIPTION_PREFIX):
|
||||
return None
|
||||
return name[len(SUBSCRIPTION_PREFIX) :] or None
|
||||
|
||||
|
||||
def auth_mode(model_name: str | None) -> str:
|
||||
return "subscription" if subscription_model(model_name) else "api_key"
|
||||
+4
-311
@@ -2,137 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from agents import (
|
||||
set_default_openai_api,
|
||||
set_default_openai_key,
|
||||
set_tracing_disabled,
|
||||
)
|
||||
from agents.model_settings import ModelSettings
|
||||
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from agents.models.openai_responses import OpenAIResponsesModel
|
||||
from agents.retry import (
|
||||
ModelRetryBackoffSettings,
|
||||
ModelRetrySettings,
|
||||
RetryPolicyContext,
|
||||
retry_policies,
|
||||
)
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from agents.models.interface import ModelProvider
|
||||
|
||||
from agents.models.interface import Model, ModelProvider
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from strix.config.settings import ReasoningEffort, Settings
|
||||
|
||||
|
||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||
"""Per-request model timeout; a plain float so ``ModelSettings.to_json_dict()`` stays serializable.""" # noqa: E501
|
||||
if not timeout_s or timeout_s <= 0:
|
||||
return None
|
||||
return {"timeout": timeout_s}
|
||||
|
||||
|
||||
def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
|
||||
"""Retry statusless provider errors (e.g. mid-stream quota/billing), but not aborts."""
|
||||
normalized = context.normalized
|
||||
if normalized.is_abort:
|
||||
return False
|
||||
if codex.is_content_guardrail_error(context.error):
|
||||
return False
|
||||
return normalized.status_code is None
|
||||
|
||||
|
||||
class _CodexResponsesModel(OpenAIResponsesModel):
|
||||
"""Responses model for the ChatGPT subscription backend (always streamed, stateless)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
openai_client: AsyncOpenAI,
|
||||
*,
|
||||
reasoning_effort: ReasoningEffort | None = None,
|
||||
) -> None:
|
||||
super().__init__(model, openai_client)
|
||||
self._reasoning_effort = reasoning_effort
|
||||
|
||||
def _codex_settings(self, model_settings: ModelSettings) -> ModelSettings:
|
||||
overrides = ModelSettings(store=False, response_include=["reasoning.encrypted_content"])
|
||||
effort = self._reasoning_effort
|
||||
if effort and effort != "none":
|
||||
# Clamp to efforts the backend accepts.
|
||||
if effort == "minimal":
|
||||
effort = "low"
|
||||
elif effort == "xhigh":
|
||||
effort = "high"
|
||||
overrides = overrides.resolve(ModelSettings(reasoning=Reasoning(effort=effort)))
|
||||
return model_settings.resolve(overrides)
|
||||
|
||||
async def _fetch_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any:
|
||||
if len(args) >= 3: # model_settings is positional arg 2
|
||||
args = (*args[:2], self._codex_settings(args[2]), *args[3:])
|
||||
try:
|
||||
events = await super()._fetch_response(*args, stream=True, **kwargs) # type: ignore[call-overload]
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
guarded = self._guarded(events)
|
||||
if stream:
|
||||
return guarded
|
||||
final_response = None
|
||||
async for event in guarded:
|
||||
if getattr(event, "type", None) == "response.completed":
|
||||
final_response = event.response
|
||||
if final_response is None:
|
||||
msg = "ChatGPT backend stream ended without a completed response"
|
||||
raise RuntimeError(msg)
|
||||
return final_response
|
||||
|
||||
def _as_guardrail(self, exc: BaseException) -> codex.CodexContentGuardrailError | None:
|
||||
if isinstance(exc, codex.CodexContentGuardrailError):
|
||||
return exc
|
||||
if codex.is_content_guardrail_error(exc):
|
||||
return codex.CodexContentGuardrailError(self.model, exc)
|
||||
return None
|
||||
|
||||
async def _guarded(self, events: Any) -> AsyncIterator[Any]:
|
||||
"""Convert mid-stream guardrail rejections and close the stream on exit."""
|
||||
try:
|
||||
async for event in events:
|
||||
yield event
|
||||
except Exception as exc:
|
||||
guardrail = self._as_guardrail(exc)
|
||||
if guardrail is not None:
|
||||
raise guardrail from exc
|
||||
raise
|
||||
finally:
|
||||
await self._aclose(events)
|
||||
|
||||
@staticmethod
|
||||
async def _aclose(events: Any) -> None:
|
||||
aclose = getattr(events, "aclose", None)
|
||||
if callable(aclose):
|
||||
with contextlib.suppress(Exception):
|
||||
await aclose()
|
||||
return
|
||||
close = getattr(events, "close", None)
|
||||
if callable(close):
|
||||
with contextlib.suppress(Exception):
|
||||
result = close()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
from strix.config.settings import Settings
|
||||
|
||||
|
||||
class StrixProvider(MultiProvider):
|
||||
@@ -158,16 +43,6 @@ class StrixProvider(MultiProvider):
|
||||
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
|
||||
return self._get_fallback_provider("litellm"), original_model_name
|
||||
|
||||
def get_model(self, model_name: str | None) -> Model:
|
||||
slug = codex.subscription_model(model_name)
|
||||
if slug:
|
||||
return _CodexResponsesModel(
|
||||
slug,
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=load_settings().llm.reasoning_effort,
|
||||
)
|
||||
return super().get_model(model_name)
|
||||
|
||||
|
||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
max_retries=5,
|
||||
@@ -181,58 +56,15 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||
retry_policies.provider_suggested(),
|
||||
retry_policies.network_error(),
|
||||
retry_policies.http_status((429, 500, 502, 503, 504)),
|
||||
_retry_statusless_provider_errors,
|
||||
),
|
||||
)
|
||||
|
||||
RECOMMENDED_MODEL_NAMES = (
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.6-luna",
|
||||
"openai/gpt-5.6",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.4",
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-opus-4-8",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"vertex_ai/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.1-pro-preview",
|
||||
"gemini/gemini-3.6-flash",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"dashscope/qwen3.8-max",
|
||||
"dashscope/qwen3.7-max-2026-06-08",
|
||||
"moonshot/kimi-k3",
|
||||
"moonshot/kimi-k2.7-code",
|
||||
)
|
||||
|
||||
_RECOMMENDED_MODEL_NAME_SET = frozenset(name.lower() for name in RECOMMENDED_MODEL_NAMES)
|
||||
|
||||
FRONTIER_MODEL_FAMILIES = (
|
||||
(("azure", "azure_ai", "bedrock_mantle", "chatgpt", "openai"), ("gpt-5",)),
|
||||
(
|
||||
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
|
||||
("claude-fable-5", "claude-opus-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
|
||||
),
|
||||
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
|
||||
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
|
||||
(("alibaba", "dashscope", "qwen"), ("qwen3.8", "qwen3.7", "qwen3-max")),
|
||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k3", "kimi-k2.7", "kimi-k2.6")),
|
||||
)
|
||||
|
||||
|
||||
def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
"""Apply Strix config to SDK-native defaults."""
|
||||
llm = settings.llm
|
||||
set_tracing_disabled(True)
|
||||
if codex.subscription_model(llm.model):
|
||||
return
|
||||
_configure_litellm_compatibility()
|
||||
_configure_openrouter_attribution(llm.model)
|
||||
if llm.api_key:
|
||||
set_default_openai_key(llm.api_key, use_for_tracing=False)
|
||||
_configure_litellm_default("api_key", llm.api_key)
|
||||
@@ -279,29 +111,6 @@ def _configure_litellm_compatibility() -> None:
|
||||
_register_litellm_cost_callback()
|
||||
|
||||
|
||||
_OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
"HTTP-Referer": "https://strix.ai",
|
||||
"X-Title": "Strix",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
}
|
||||
|
||||
|
||||
def _configure_openrouter_attribution(model_name: str | None) -> None:
|
||||
import litellm
|
||||
|
||||
current: object = litellm.headers
|
||||
existing: dict[str, str] = current if isinstance(current, dict) else {}
|
||||
if not model_name or "openrouter/" not in model_name.strip().lower():
|
||||
if any(key in existing for key in _OPENROUTER_ATTRIBUTION_HEADERS):
|
||||
remaining = {
|
||||
k: v for k, v in existing.items() if k not in _OPENROUTER_ATTRIBUTION_HEADERS
|
||||
}
|
||||
litellm.headers = remaining or None # type: ignore[assignment]
|
||||
return
|
||||
|
||||
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
|
||||
|
||||
|
||||
def _register_litellm_cost_callback() -> None:
|
||||
import litellm
|
||||
|
||||
@@ -325,8 +134,6 @@ def _configure_litellm_default(name: str, value: str) -> None:
|
||||
|
||||
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
||||
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
||||
if codex.subscription_model(model_name):
|
||||
return False
|
||||
model = model_name.strip().lower()
|
||||
if "/" in model and not model.startswith("openai/"):
|
||||
return True
|
||||
@@ -349,78 +156,6 @@ def model_supports_reasoning(model_name: str) -> bool:
|
||||
return bool(entry and entry.get("supports_reasoning"))
|
||||
|
||||
|
||||
def is_recommended_or_frontier_model(model_name: str) -> bool:
|
||||
"""Return whether a model is recommended or in a frontier model family."""
|
||||
name = _normalized_model_name(model_name)
|
||||
if not name:
|
||||
return False
|
||||
if name in _RECOMMENDED_MODEL_NAME_SET:
|
||||
return True
|
||||
provider_name, bare_model_name = _split_model_provider(name)
|
||||
return any(
|
||||
_matches_frontier_family(provider_name, bare_model_name, provider_markers, prefixes)
|
||||
for provider_markers, prefixes in FRONTIER_MODEL_FAMILIES
|
||||
)
|
||||
|
||||
|
||||
def _normalized_model_name(model_name: str) -> str:
|
||||
name = model_name.strip().lower()
|
||||
for prefix in ("litellm/", "any-llm/"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
return name
|
||||
|
||||
|
||||
def _split_model_provider(model_name: str) -> tuple[str | None, str]:
|
||||
if "/" not in model_name:
|
||||
return None, model_name
|
||||
provider_name, bare_model_name = model_name.rsplit("/", 1)
|
||||
return provider_name, bare_model_name
|
||||
|
||||
|
||||
def _matches_frontier_family(
|
||||
provider_name: str | None,
|
||||
model_name: str,
|
||||
provider_markers: tuple[str, ...],
|
||||
model_prefixes: tuple[str, ...],
|
||||
) -> bool:
|
||||
if not _matches_model_prefix(model_name, model_prefixes):
|
||||
return False
|
||||
if provider_name is None:
|
||||
return True
|
||||
return _contains_provider_marker(
|
||||
provider_name, provider_markers, split_compound_names=True
|
||||
) or _contains_provider_marker(model_name, provider_markers)
|
||||
|
||||
|
||||
def _matches_model_prefix(model_name: str, model_prefixes: tuple[str, ...]) -> bool:
|
||||
return any(
|
||||
candidate.startswith(prefix)
|
||||
for candidate in _model_name_candidates(model_name)
|
||||
for prefix in model_prefixes
|
||||
)
|
||||
|
||||
|
||||
def _model_name_candidates(model_name: str) -> tuple[str, ...]:
|
||||
if "." not in model_name:
|
||||
return (model_name,)
|
||||
suffixes = tuple(
|
||||
model_name.split(".", index)[-1] for index in range(1, model_name.count(".") + 1)
|
||||
)
|
||||
return (model_name, *suffixes)
|
||||
|
||||
|
||||
def _contains_provider_marker(
|
||||
value: str, provider_markers: tuple[str, ...], *, split_compound_names: bool = False
|
||||
) -> bool:
|
||||
parts = set(value.replace(".", "/").split("/"))
|
||||
if split_compound_names:
|
||||
for separator in ("_", "-"):
|
||||
parts.update(piece for part in tuple(parts) for piece in part.split(separator))
|
||||
return any(marker in parts for marker in provider_markers)
|
||||
|
||||
|
||||
def is_known_openai_bare_model(model_name: str) -> bool:
|
||||
import litellm
|
||||
|
||||
@@ -429,45 +164,3 @@ def is_known_openai_bare_model(model_name: str) -> bool:
|
||||
return False
|
||||
entry = litellm.model_cost.get(name)
|
||||
return bool(entry and entry.get("litellm_provider") == "openai")
|
||||
|
||||
|
||||
def is_claude_model(model_name: str) -> bool:
|
||||
return "claude" in (model_name or "").strip().lower()
|
||||
|
||||
|
||||
def is_bedrock_route(model_name: str) -> bool:
|
||||
name = (model_name or "").strip().lower()
|
||||
return name.startswith("bedrock/") or "anthropic." in name
|
||||
|
||||
|
||||
def _prompt_cache_name_candidates(model_name: str) -> list[str]:
|
||||
# LiteLLM's model map keys the same model under several names; strip the
|
||||
# route prefix, then leading dotted segments (region, provider).
|
||||
name = (model_name or "").strip().lower()
|
||||
for prefix in ("litellm/", "bedrock/"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
candidates = [name]
|
||||
rest = name
|
||||
while "." in rest:
|
||||
rest = rest.split(".", 1)[1]
|
||||
candidates.append(rest)
|
||||
return candidates
|
||||
|
||||
|
||||
def bedrock_route_supports_prompt_caching(model_name: str) -> bool:
|
||||
# Bedrock rejects the cache marker for models LiteLLM's map doesn't
|
||||
# recognise as cache-capable, so callers withhold it unless confirmed here.
|
||||
import litellm
|
||||
|
||||
checker = getattr(getattr(litellm, "utils", None), "supports_prompt_caching", None)
|
||||
for cand in _prompt_cache_name_candidates(model_name):
|
||||
if checker is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
if checker(cand):
|
||||
return True
|
||||
entry = litellm.model_cost.get(cand)
|
||||
if entry and entry.get("supports_prompt_caching"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -40,50 +40,14 @@ class LlmSettings(BaseSettings):
|
||||
default=False,
|
||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||
)
|
||||
prompt_cache: bool = Field(
|
||||
default=True,
|
||||
alias="STRIX_PROMPT_CACHE",
|
||||
)
|
||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||
|
||||
|
||||
class DedupeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
|
||||
reasoning_effort: ReasoningEffort | None = Field(
|
||||
default=None,
|
||||
alias="STRIX_DEDUPE_REASONING_EFFORT",
|
||||
)
|
||||
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
||||
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||
|
||||
|
||||
class ContextSettings(BaseSettings):
|
||||
"""Context-window management: per-tool-output caps and history compaction."""
|
||||
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
|
||||
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
|
||||
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
|
||||
fallback_context_tokens: int = Field(
|
||||
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
|
||||
)
|
||||
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
|
||||
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
|
||||
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
|
||||
# Floor above the truncation-notice size so a preview always fits.
|
||||
tool_output_max_bytes: int = Field(
|
||||
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
image: str = Field(
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.1.0",
|
||||
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
|
||||
alias="STRIX_IMAGE",
|
||||
)
|
||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
||||
@@ -92,8 +56,6 @@ class RuntimeSettings(BaseSettings):
|
||||
# on large repos). Above this, the user must bind-mount via ``--mount``.
|
||||
# Set to 0 (or less) to disable the pre-flight check entirely.
|
||||
max_local_copy_mb: int = Field(default=1024, alias="STRIX_MAX_LOCAL_COPY_MB")
|
||||
# Max screenshot/image tool outputs kept live per agent context (0 = none).
|
||||
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
|
||||
|
||||
|
||||
class TelemetrySettings(BaseSettings):
|
||||
@@ -108,22 +70,10 @@ class IntegrationSettings(BaseSettings):
|
||||
perplexity_api_key: str | None = Field(default=None, alias="PERPLEXITY_API_KEY")
|
||||
|
||||
|
||||
class ViewerSettings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
# Base URL of the Strix relay the local viewer proxies to for email
|
||||
# verification and encrypted report delivery. The browser never talks to
|
||||
# the relay directly; the local server is the only caller.
|
||||
app_url: str = Field(default="https://app.strix.ai", alias="STRIX_APP_URL")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = _BASE_CONFIG
|
||||
|
||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
|
||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||
context: ContextSettings = Field(default_factory=ContextSettings)
|
||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||
|
||||
+6
-103
@@ -10,19 +10,15 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from strix.core.sessions import session_write_lock
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -43,15 +39,11 @@ class AgentCoordinator:
|
||||
self.names: dict[str, str] = {}
|
||||
self.metadata: dict[str, dict[str, Any]] = {}
|
||||
self.pending_counts: dict[str, int] = {}
|
||||
self.errors: dict[str, str] = {}
|
||||
self.runtimes: dict[str, AgentRuntime] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._snapshot_path: Path | None = None
|
||||
self.is_shutting_down = False
|
||||
self._budget_stopped = False
|
||||
self._reserve_stopped = False
|
||||
self._budget_paused = False
|
||||
self._extend_budget: Callable[[], None] | None = None
|
||||
|
||||
def set_snapshot_path(self, path: Path) -> None:
|
||||
self._snapshot_path = path
|
||||
@@ -70,71 +62,6 @@ class AgentCoordinator:
|
||||
for runtime in self.runtimes.values():
|
||||
runtime.wake.set()
|
||||
|
||||
@property
|
||||
def reserve_stopped(self) -> bool:
|
||||
return self._reserve_stopped
|
||||
|
||||
@property
|
||||
def budget_paused(self) -> bool:
|
||||
return self._budget_paused
|
||||
|
||||
def set_budget_extender(self, extend: Callable[[], None]) -> None:
|
||||
self._extend_budget = extend
|
||||
|
||||
async def pause_for_budget(self, agent_id: str) -> None:
|
||||
async with self._lock:
|
||||
self._budget_paused = True
|
||||
await self.set_status(agent_id, "budget_paused")
|
||||
|
||||
async def resume_from_budget_pause(self, *, exclude: str | None = None) -> None:
|
||||
async with self._lock:
|
||||
if not self._budget_paused:
|
||||
return
|
||||
self._budget_paused = False
|
||||
paused = [aid for aid, status in self.statuses.items() if status == "budget_paused"]
|
||||
if self._extend_budget is not None:
|
||||
self._extend_budget()
|
||||
for aid in paused:
|
||||
await self.set_status(aid, "waiting")
|
||||
if aid != exclude:
|
||||
await self.send(
|
||||
aid,
|
||||
{
|
||||
"from": "system",
|
||||
"type": "budget_extended",
|
||||
"content": (
|
||||
"[Budget] The user extended the scan budget \u2014 continue your "
|
||||
"current task."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def reset_budget_stops(
|
||||
self,
|
||||
*,
|
||||
budget_stopped: bool,
|
||||
reserve_stopped: bool,
|
||||
budget_paused: bool = False,
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
self._budget_stopped = budget_stopped
|
||||
self._reserve_stopped = reserve_stopped
|
||||
if not budget_paused:
|
||||
self._budget_paused = False
|
||||
for aid, status in self.statuses.items():
|
||||
if status == "budget_paused":
|
||||
self.statuses[aid] = "waiting"
|
||||
await self._maybe_snapshot()
|
||||
|
||||
async def claim_reserve_notification(self) -> str | None:
|
||||
async with self._lock:
|
||||
if self._reserve_stopped:
|
||||
return None
|
||||
self._reserve_stopped = True
|
||||
for runtime in self.runtimes.values():
|
||||
runtime.wake.set()
|
||||
return next((aid for aid, parent in self.parent_of.items() if parent is None), None)
|
||||
|
||||
async def register(
|
||||
self,
|
||||
agent_id: str,
|
||||
@@ -178,23 +105,16 @@ class AgentCoordinator:
|
||||
async with self._lock:
|
||||
if agent_id in self.statuses:
|
||||
self.statuses[agent_id] = "running"
|
||||
self.errors.pop(agent_id, None)
|
||||
await self._maybe_snapshot()
|
||||
|
||||
async def park_waiting(self, agent_id: str) -> None:
|
||||
await self.set_status(agent_id, "waiting")
|
||||
|
||||
async def set_status(
|
||||
self, agent_id: str, status: Status | str, *, error: str | None = None
|
||||
) -> None:
|
||||
async def set_status(self, agent_id: str, status: Status | str) -> None:
|
||||
async with self._lock:
|
||||
if agent_id not in self.statuses:
|
||||
return
|
||||
self.statuses[agent_id] = status # type: ignore[assignment]
|
||||
if error is not None:
|
||||
self.errors[agent_id] = error
|
||||
elif status == "running":
|
||||
self.errors.pop(agent_id, None)
|
||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
||||
runtime.wake.set()
|
||||
logger.info("agent.status %s=%s", agent_id, status)
|
||||
@@ -202,8 +122,6 @@ class AgentCoordinator:
|
||||
|
||||
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
|
||||
"""Deliver a user/peer message by appending it to the target SDK session."""
|
||||
if message.get("from") == "user" and self._budget_paused:
|
||||
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||
async with self._lock:
|
||||
if target_agent_id not in self.statuses:
|
||||
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
||||
@@ -219,8 +137,7 @@ class AgentCoordinator:
|
||||
)
|
||||
return False
|
||||
try:
|
||||
async with session_write_lock(session):
|
||||
await session.add_items([self._message_to_session_item(message)])
|
||||
await session.add_items([self._message_to_session_item(message)])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"agent.send failed to append to SDK session target=%s",
|
||||
@@ -238,8 +155,7 @@ class AgentCoordinator:
|
||||
async def wait_for_message(self, agent_id: str) -> None:
|
||||
while True:
|
||||
async with self._lock:
|
||||
reserve_exit = self._reserve_stopped and self.parent_of.get(agent_id) is not None
|
||||
if self._budget_stopped or reserve_exit or self.pending_counts.get(agent_id, 0) > 0:
|
||||
if self._budget_stopped or self.pending_counts.get(agent_id, 0) > 0:
|
||||
return
|
||||
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
|
||||
wake.clear()
|
||||
@@ -327,14 +243,9 @@ class AgentCoordinator:
|
||||
|
||||
async def graph_snapshot(
|
||||
self,
|
||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str], dict[str, str]]:
|
||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str]]:
|
||||
async with self._lock:
|
||||
return (
|
||||
dict(self.parent_of),
|
||||
dict(self.statuses),
|
||||
dict(self.names),
|
||||
dict(self.errors),
|
||||
)
|
||||
return dict(self.parent_of), dict(self.statuses), dict(self.names)
|
||||
|
||||
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
|
||||
sender = str(message.get("from", "unknown"))
|
||||
@@ -372,10 +283,6 @@ class AgentCoordinator:
|
||||
"names": dict(self.names),
|
||||
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
||||
"pending_counts": dict(self.pending_counts),
|
||||
"errors": dict(self.errors),
|
||||
"budget_stopped": self._budget_stopped,
|
||||
"reserve_stopped": self._reserve_stopped,
|
||||
"budget_paused": self._budget_paused,
|
||||
}
|
||||
|
||||
async def restore(self, snap: dict[str, Any]) -> None:
|
||||
@@ -385,10 +292,6 @@ class AgentCoordinator:
|
||||
self.names = dict(snap.get("names", {}))
|
||||
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
||||
self.pending_counts = dict(snap.get("pending_counts", {}))
|
||||
self.errors = dict(snap.get("errors", {}))
|
||||
self._budget_stopped = bool(snap.get("budget_stopped", False))
|
||||
self._reserve_stopped = bool(snap.get("reserve_stopped", False))
|
||||
self._budget_paused = bool(snap.get("budget_paused", False))
|
||||
for aid in self.statuses:
|
||||
self.runtimes.setdefault(aid, AgentRuntime())
|
||||
|
||||
|
||||
+44
-237
@@ -13,26 +13,11 @@ from agents import RunConfig, Runner
|
||||
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
||||
from agents.sandbox.errors import ExecTransportError
|
||||
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
||||
from openai import (
|
||||
APIConnectionError,
|
||||
APIError,
|
||||
APIStatusError,
|
||||
APITimeoutError,
|
||||
RateLimitError,
|
||||
)
|
||||
from openai import APIError
|
||||
|
||||
from strix.core.hooks import (
|
||||
BudgetExceededError,
|
||||
BudgetPausedError,
|
||||
SubagentBudgetReservedError,
|
||||
)
|
||||
from strix.core.hooks import BudgetExceededError
|
||||
from strix.core.inputs import child_initial_input
|
||||
from strix.core.sessions import (
|
||||
enforce_image_budget,
|
||||
open_agent_session,
|
||||
strip_all_images_from_session,
|
||||
)
|
||||
from strix.llm.compaction import is_context_overflow, maybe_compact
|
||||
from strix.core.sessions import open_agent_session, strip_all_images_from_session
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -51,69 +36,6 @@ logger = logging.getLogger(__name__)
|
||||
StreamEventSink = Callable[[str, Any], None]
|
||||
|
||||
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
||||
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||
|
||||
|
||||
def _run_config_model(run_config: RunConfig) -> str | None:
|
||||
return run_config.model if isinstance(run_config.model, str) else None
|
||||
|
||||
|
||||
def _agent_instructions(agent: Any) -> str:
|
||||
instructions = getattr(agent, "instructions", None)
|
||||
return instructions if isinstance(instructions, str) else ""
|
||||
|
||||
|
||||
def _agent_tools_text(agent: Any) -> str:
|
||||
parts: list[str] = []
|
||||
for tool in getattr(agent, "tools", []) or []:
|
||||
name = getattr(tool, "name", "")
|
||||
description = getattr(tool, "description", "") or ""
|
||||
schema = getattr(tool, "params_json_schema", "") or ""
|
||||
parts.append(f"{name} {description} {schema}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def _compact_session(
|
||||
agent: Any, session: Session, run_config: RunConfig, *, force: bool
|
||||
) -> bool:
|
||||
model = _run_config_model(run_config)
|
||||
if session is None or model is None:
|
||||
return False
|
||||
return await maybe_compact(
|
||||
session,
|
||||
model=model,
|
||||
instructions=_agent_instructions(agent),
|
||||
tools_text=_agent_tools_text(agent),
|
||||
force=force,
|
||||
)
|
||||
|
||||
|
||||
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
|
||||
_MAX_TRANSIENT_MODEL_RETRIES = 4
|
||||
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
|
||||
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 30.0
|
||||
|
||||
|
||||
def _model_error_status_code(exc: BaseException) -> int | None:
|
||||
code = getattr(exc, "status_code", None)
|
||||
return code if isinstance(code, int) else None
|
||||
|
||||
|
||||
def _is_transient_model_error(exc: BaseException) -> bool:
|
||||
if isinstance(exc, RateLimitError):
|
||||
return False
|
||||
if isinstance(exc, APITimeoutError | APIConnectionError):
|
||||
return True
|
||||
if isinstance(exc, APIStatusError):
|
||||
return exc.status_code in _TRANSIENT_MODEL_STATUS_CODES
|
||||
if isinstance(exc, APIError):
|
||||
return _model_error_status_code(exc) is None
|
||||
return False
|
||||
|
||||
|
||||
def _transient_model_retry_delay(attempt: int) -> float:
|
||||
delay = _TRANSIENT_MODEL_RETRY_BASE_DELAY_S * float(2 ** (attempt - 1))
|
||||
return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S)
|
||||
|
||||
|
||||
async def run_agent_loop(
|
||||
@@ -138,34 +60,21 @@ async def run_agent_loop(
|
||||
)
|
||||
result: RunResultBase | None = None
|
||||
|
||||
budget_stopped = coordinator.budget_stopped
|
||||
reserve_stopped = coordinator.reserve_stopped
|
||||
if budget_stopped:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise BudgetExceededError("scan budget reached")
|
||||
if reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
if reserve_stopped and start_parked and interactive and context.get("parent_id") is None:
|
||||
await coordinator.send(agent_id, _reserve_notice())
|
||||
|
||||
if not (start_parked and interactive):
|
||||
if interactive:
|
||||
with contextlib.suppress(BudgetPausedError):
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=initial_input,
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=initial_input,
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
else:
|
||||
result = await _run_noninteractive_until_lifecycle(
|
||||
agent,
|
||||
@@ -193,25 +102,20 @@ async def run_agent_loop(
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise BudgetExceededError("scan budget reached")
|
||||
|
||||
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
await coordinator.consume_pending(agent_id)
|
||||
with contextlib.suppress(BudgetPausedError):
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=[],
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
agent_id,
|
||||
input_data=[],
|
||||
run_config=run_config,
|
||||
context=context,
|
||||
max_turns=max_turns,
|
||||
session=session,
|
||||
interactive=interactive,
|
||||
event_sink=event_sink,
|
||||
hooks=hooks,
|
||||
)
|
||||
|
||||
|
||||
async def spawn_child_agent(
|
||||
@@ -383,10 +287,6 @@ async def _run_noninteractive_until_lifecycle(
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise BudgetExceededError("scan budget reached")
|
||||
|
||||
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||
|
||||
result = await _run_cycle(
|
||||
agent,
|
||||
coordinator,
|
||||
@@ -417,7 +317,7 @@ async def _run_noninteractive_until_lifecycle(
|
||||
|
||||
if invalid_final_outputs >= invalid_final_output_limit:
|
||||
await coordinator.set_status(agent_id, "crashed")
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, "crashed")
|
||||
await _notify_parent_on_crash(coordinator, agent_id, "crashed")
|
||||
raise MaxTurnsExceeded(
|
||||
"Agent exhausted non-interactive recovery attempts without calling "
|
||||
"finish_scan or agent_finish."
|
||||
@@ -446,22 +346,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
hooks: RunHooks[dict[str, Any]] | None,
|
||||
) -> RunResultBase | None:
|
||||
image_strips = 0
|
||||
compactions = 0
|
||||
model_retries = 0
|
||||
while True:
|
||||
try:
|
||||
await coordinator.mark_running(agent_id)
|
||||
if session is not None:
|
||||
max_images = context.get("max_context_images")
|
||||
if isinstance(max_images, int):
|
||||
try:
|
||||
await enforce_image_budget(session, max_images)
|
||||
except Exception:
|
||||
logger.exception("image-budget enforcement failed for %s", agent_id)
|
||||
try:
|
||||
await _compact_session(agent, session, run_config, force=False)
|
||||
except Exception:
|
||||
logger.exception("proactive compaction failed for %s", agent_id)
|
||||
stream = Runner.run_streamed(
|
||||
agent,
|
||||
input=input_data,
|
||||
@@ -482,7 +369,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
logger.exception("stream event sink failed for %s", agent_id)
|
||||
if stream.run_loop_exception is not None:
|
||||
raise stream.run_loop_exception
|
||||
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
|
||||
except BudgetExceededError:
|
||||
# A RuntimeError subclass: re-raise explicitly so it is never
|
||||
# mistaken for the LiteLLM "after shutdown" race below.
|
||||
raise
|
||||
except RuntimeError as stream_exc:
|
||||
if "after shutdown" not in str(stream_exc):
|
||||
@@ -501,15 +390,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
)
|
||||
finally:
|
||||
await coordinator.detach_stream(agent_id, stream)
|
||||
except BudgetPausedError as exc:
|
||||
logger.info("agent %s paused at the scan budget limit: %s", agent_id, exc)
|
||||
await coordinator.pause_for_budget(agent_id)
|
||||
raise
|
||||
except SubagentBudgetReservedError as exc:
|
||||
logger.info("sub-agent %s stopped at the budget reserve: %s", agent_id, exc)
|
||||
await coordinator.set_status(agent_id, "stopped")
|
||||
await _notify_root_on_budget_reserve(coordinator)
|
||||
raise
|
||||
except BudgetExceededError as exc:
|
||||
logger.info(
|
||||
"agent %s reached the scan budget limit; stopping the scan: %s", agent_id, exc
|
||||
@@ -537,41 +417,6 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if (
|
||||
compactions < _MAX_COMPACTIONS_PER_CYCLE
|
||||
and session is not None
|
||||
and is_context_overflow(exc)
|
||||
):
|
||||
try:
|
||||
compacted = await _compact_session(agent, session, run_config, force=True)
|
||||
except Exception:
|
||||
logger.exception("overflow compaction recovery failed for %s", agent_id)
|
||||
compacted = False
|
||||
if compacted:
|
||||
compactions += 1
|
||||
logger.info(
|
||||
"Compacted %s session after context overflow; retrying (%d)",
|
||||
agent_id,
|
||||
compactions,
|
||||
)
|
||||
input_data = []
|
||||
continue
|
||||
if model_retries < _MAX_TRANSIENT_MODEL_RETRIES and _is_transient_model_error(exc):
|
||||
model_retries += 1
|
||||
delay = _transient_model_retry_delay(model_retries)
|
||||
logger.warning(
|
||||
"transient model/provider error for %s; replaying turn "
|
||||
"(attempt %d/%d, backoff %.1fs): %r",
|
||||
agent_id,
|
||||
model_retries,
|
||||
_MAX_TRANSIENT_MODEL_RETRIES,
|
||||
delay,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
if session is not None:
|
||||
input_data = []
|
||||
continue
|
||||
if not interactive:
|
||||
raise
|
||||
if isinstance(exc, MaxTurnsExceeded):
|
||||
@@ -581,8 +426,10 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
||||
else:
|
||||
status = "crashed"
|
||||
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
|
||||
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
|
||||
await _notify_parent_on_terminal(coordinator, agent_id, status)
|
||||
await coordinator.set_status(agent_id, status)
|
||||
await _notify_parent_on_crash(coordinator, agent_id, status)
|
||||
if context.get("parent_id") is None and status in {"failed", "crashed"}:
|
||||
raise
|
||||
return None
|
||||
else:
|
||||
await _settle_run_result(coordinator, agent_id, interactive)
|
||||
@@ -646,31 +493,12 @@ async def _append_noninteractive_tool_required_message(
|
||||
return []
|
||||
|
||||
|
||||
_TERMINAL_NOTICE = {
|
||||
"crashed": (
|
||||
"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
||||
"Stop waiting on this child unless you want to message it again."
|
||||
),
|
||||
"failed": (
|
||||
"[Agent failed] {name} ({agent_id}) stopped with an error and will not "
|
||||
"send a completion report. Stop waiting on this child unless you want to "
|
||||
"message it again."
|
||||
),
|
||||
"stopped": (
|
||||
"[Agent capped] {name} ({agent_id}) hit its turn limit and was stopped "
|
||||
"before finishing. It will not send a completion report, so stop waiting "
|
||||
"on this child; account for its capped subtask and continue."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _notify_parent_on_terminal(
|
||||
async def _notify_parent_on_crash(
|
||||
coordinator: AgentCoordinator,
|
||||
agent_id: str,
|
||||
status: str,
|
||||
) -> None:
|
||||
template = _TERMINAL_NOTICE.get(status)
|
||||
if template is None:
|
||||
if status != "crashed":
|
||||
return
|
||||
async with coordinator._lock:
|
||||
parent = coordinator.parent_of.get(agent_id)
|
||||
@@ -681,35 +509,16 @@ async def _notify_parent_on_terminal(
|
||||
parent,
|
||||
{
|
||||
"from": agent_id,
|
||||
"type": status,
|
||||
"type": "crash",
|
||||
"priority": "high",
|
||||
"content": template.format(name=name, agent_id=agent_id),
|
||||
"content": (
|
||||
f"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
||||
"Stop waiting on this child unless you want to message it again."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _reserve_notice() -> dict[str, Any]:
|
||||
return {
|
||||
"from": "system",
|
||||
"type": "budget_reserve_stop",
|
||||
"priority": "high",
|
||||
"content": (
|
||||
"[Budget reserve] The scan has reached the sub-agent budget reserve: every "
|
||||
"sub-agent is being force-stopped as soon as its in-flight turn completes, and "
|
||||
"none will send a completion report. Their confirmed vulnerabilities are "
|
||||
"already filed as they were found. Do not wait on any sub-agents and do not "
|
||||
"spawn new ones — wrap up now and call finish_scan."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
|
||||
root = await coordinator.claim_reserve_notification()
|
||||
if root is None:
|
||||
return
|
||||
await coordinator.send(root, _reserve_notice())
|
||||
|
||||
|
||||
async def _start_child_runner(
|
||||
*,
|
||||
parent_ctx: dict[str, Any],
|
||||
@@ -761,8 +570,6 @@ async def _start_child_runner(
|
||||
)
|
||||
except BudgetExceededError:
|
||||
logger.info("child %s stopped after reaching the scan budget limit", child_id)
|
||||
except SubagentBudgetReservedError:
|
||||
logger.info("child %s stopped at the sub-agent budget reserve", child_id)
|
||||
|
||||
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
|
||||
await coordinator.attach_runtime(child_id, task=task_handle)
|
||||
|
||||
+5
-206
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents.lifecycle import RunHooks
|
||||
@@ -14,210 +13,25 @@ from strix.report.state import get_global_report_state
|
||||
if TYPE_CHECKING:
|
||||
from agents import RunContextWrapper
|
||||
from agents.agent import Agent
|
||||
from agents.items import ModelResponse, TResponseInputItem
|
||||
from agents.items import ModelResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
|
||||
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||
_SUBAGENT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.75, 0.80, 0.85)
|
||||
_SUBAGENT_BUDGET_RESERVE = 0.90
|
||||
|
||||
|
||||
class BudgetExceededError(RuntimeError):
|
||||
"""Raised when the accumulated LLM cost reaches the configured budget."""
|
||||
|
||||
|
||||
class SubagentBudgetReservedError(RuntimeError):
|
||||
"""Raised to stop a single sub-agent once the reserve threshold is crossed."""
|
||||
|
||||
|
||||
class BudgetPausedError(RuntimeError):
|
||||
"""Raised to park one agent when an interactive scan reaches its budget."""
|
||||
|
||||
|
||||
def recomputed_budget_flags(
|
||||
cost: float,
|
||||
max_budget_usd: float | None,
|
||||
*,
|
||||
interactive: bool,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Return the (budget_stopped, reserve_stopped) flags a resumed scan should carry."""
|
||||
if max_budget_usd is None:
|
||||
return False, False
|
||||
if interactive:
|
||||
return False, False
|
||||
budget_stopped = cost >= max_budget_usd
|
||||
reserve_stopped = cost >= max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||
return budget_stopped, reserve_stopped
|
||||
|
||||
|
||||
def _crossed_stage(fraction: float, bands: tuple[float, ...]) -> int | None:
|
||||
crossed: int | None = None
|
||||
for index, band in enumerate(bands):
|
||||
if fraction >= band:
|
||||
crossed = index
|
||||
return crossed
|
||||
|
||||
|
||||
_ROOT_DIRECTIVES: tuple[str, ...] = (
|
||||
(
|
||||
"As the root agent, begin planning your wind-down of the whole scan: avoid "
|
||||
"starting large new lines of investigation, and keep your required objectives on "
|
||||
"track so you can call finish_scan comfortably before the limit."
|
||||
),
|
||||
(
|
||||
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
|
||||
"lines of investigation, close out only what is essential, and move toward calling "
|
||||
"finish_scan to compile and deliver the final report."
|
||||
),
|
||||
(
|
||||
"As the root agent, STOP all other work on the whole scan and finish immediately: "
|
||||
"secure your findings and call finish_scan now — anything left unfinished when the "
|
||||
"limit is hit is discarded."
|
||||
),
|
||||
)
|
||||
_SUBAGENT_DIRECTIVES: tuple[str, ...] = (
|
||||
(
|
||||
"As a sub-agent, begin planning your wind-down: avoid starting large new subtasks, "
|
||||
"and if you are close to a confirmed, validated vulnerability, drive it to a result "
|
||||
"you can report."
|
||||
),
|
||||
(
|
||||
"As a sub-agent, prioritize wrapping up your task now: report any confirmed, "
|
||||
"validated vulnerability, finish work that is nearly done rather than starting "
|
||||
"anything new, and prepare to call agent_finish."
|
||||
),
|
||||
(
|
||||
"As a sub-agent, STOP all other work and finish immediately: report any confirmed "
|
||||
"vulnerability right now and call agent_finish to hand your results back to your "
|
||||
"parent before you are cut off."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
|
||||
is_root = context.context.get("parent_id") is None
|
||||
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
|
||||
return directives[stage]
|
||||
|
||||
|
||||
def _urgency(stage: int) -> str:
|
||||
return _STAGE_LABELS[stage]
|
||||
|
||||
|
||||
class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
"""Persist SDK-native usage and warn/stop as turn and cost budgets are consumed."""
|
||||
"""Persist SDK-native usage after every model response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
max_budget_usd: float | None = None,
|
||||
max_turns: int | None = None,
|
||||
interactive: bool = False,
|
||||
) -> None:
|
||||
if max_budget_usd is not None and (
|
||||
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
|
||||
):
|
||||
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
|
||||
import math
|
||||
if max_budget_usd is not None and (not math.isfinite(max_budget_usd) or max_budget_usd <= 0):
|
||||
raise ValueError("max_budget_usd must be a finite number greater than 0")
|
||||
if max_turns is not None and max_turns <= 0:
|
||||
raise ValueError("max_turns must be a positive integer")
|
||||
self._model = model
|
||||
self._max_budget_usd = max_budget_usd
|
||||
self._budget_increment = max_budget_usd
|
||||
self._max_turns = max_turns
|
||||
self._interactive = interactive
|
||||
|
||||
def extend_budget(self) -> None:
|
||||
if self._max_budget_usd is None or self._budget_increment is None:
|
||||
return
|
||||
self._max_budget_usd += self._budget_increment
|
||||
|
||||
async def on_llm_start(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
agent: Agent[dict[str, Any]], # noqa: ARG002
|
||||
system_prompt: str | None, # noqa: ARG002
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
try:
|
||||
self._maybe_warn_turns(context, input_items)
|
||||
self._maybe_warn_budget(context, input_items)
|
||||
except Exception:
|
||||
logger.exception("budget/turn warning injection failed")
|
||||
|
||||
def _maybe_warn_turns(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
if not self._max_turns:
|
||||
return
|
||||
usage = getattr(context, "usage", None)
|
||||
requests = getattr(usage, "requests", None)
|
||||
if not isinstance(requests, int):
|
||||
return
|
||||
turns_used = requests + 1
|
||||
stage = _crossed_stage(turns_used / self._max_turns, _TURN_WARN_BANDS)
|
||||
if stage is None:
|
||||
return
|
||||
remaining = max(self._max_turns - turns_used, 0)
|
||||
pct = round(100 * turns_used / self._max_turns)
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Turn budget: {turns_used}/{self._max_turns} used ({pct}%). "
|
||||
f"About {remaining} turn(s) remain before this agent is force-stopped and any "
|
||||
f"in-progress work is discarded. {_wrapup_directive(context, stage)}"
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
def _maybe_warn_budget(
|
||||
self,
|
||||
context: RunContextWrapper[dict[str, Any]],
|
||||
input_items: list[TResponseInputItem],
|
||||
) -> None:
|
||||
if self._max_budget_usd is None:
|
||||
return
|
||||
report_state = get_global_report_state()
|
||||
if report_state is None:
|
||||
return
|
||||
cost = report_state.get_total_llm_cost()
|
||||
is_root = context.context.get("parent_id") is None
|
||||
if self._interactive:
|
||||
bands = _ROOT_BUDGET_WARN_BANDS
|
||||
else:
|
||||
bands = _ROOT_BUDGET_WARN_BANDS if is_root else _SUBAGENT_BUDGET_WARN_BANDS
|
||||
stage = _crossed_stage(cost / self._max_budget_usd, bands)
|
||||
if stage is None:
|
||||
return
|
||||
pct = round(100 * cost / self._max_budget_usd)
|
||||
reserve_pct = round(_SUBAGENT_BUDGET_RESERVE * 100)
|
||||
if self._interactive:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||
"is reached all agents are paused until the user chooses to continue. "
|
||||
f"{_wrapup_directive(context, stage)}"
|
||||
)
|
||||
elif is_root:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||
"is reached the whole scan is stopped immediately, and sub-agents are stopped at "
|
||||
f"{reserve_pct}% to reserve the remainder for your final report. "
|
||||
f"{_wrapup_directive(context, stage)}"
|
||||
)
|
||||
else:
|
||||
content = (
|
||||
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||
f"spent ({pct}%). This budget is shared across every agent in the scan; "
|
||||
f"sub-agents are stopped at {reserve_pct}% to leave the remainder for the root "
|
||||
f"agent's final report. {_wrapup_directive(context, stage)}"
|
||||
)
|
||||
input_items.append({"role": "user", "content": content})
|
||||
|
||||
async def on_llm_end(
|
||||
self,
|
||||
@@ -250,21 +64,6 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||
if self._max_budget_usd is not None:
|
||||
cost = report_state.get_total_llm_cost()
|
||||
if cost >= self._max_budget_usd:
|
||||
if self._interactive:
|
||||
raise BudgetPausedError(
|
||||
f"Scan budget of ${self._max_budget_usd:.2f} reached "
|
||||
f"(spent ${cost:.4f}); pausing until the user continues"
|
||||
)
|
||||
raise BudgetExceededError(
|
||||
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
|
||||
)
|
||||
is_root = ctx.get("parent_id") is None
|
||||
if not self._interactive and not is_root:
|
||||
reserve_limit = self._max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||
if cost >= reserve_limit:
|
||||
raise SubagentBudgetReservedError(
|
||||
f"Sub-agent budget reserve reached: spent ${cost:.4f} of "
|
||||
f"${self._max_budget_usd:.2f} "
|
||||
f"(>= {round(_SUBAGENT_BUDGET_RESERVE * 100)}% reserve); stopping this "
|
||||
"sub-agent so the root agent can finish the scan."
|
||||
)
|
||||
|
||||
+1
-46
@@ -10,14 +10,9 @@ from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config.models import (
|
||||
DEFAULT_MODEL_RETRY,
|
||||
bedrock_route_supports_prompt_caching,
|
||||
is_bedrock_route,
|
||||
is_claude_model,
|
||||
is_known_openai_bare_model,
|
||||
model_supports_reasoning,
|
||||
request_timeout_extra_args,
|
||||
)
|
||||
from strix.core.sessions import scrub_images_from_items
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -29,10 +24,6 @@ DEFAULT_MAX_TURNS = 500
|
||||
|
||||
def _accepts_required_tool_choice(model_name: str | None) -> bool:
|
||||
name = (model_name or "").strip().lower()
|
||||
for prefix in ("litellm/", "any-llm/"):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
return name.startswith("openai/") or is_known_openai_bare_model(name)
|
||||
|
||||
|
||||
@@ -130,14 +121,11 @@ def make_model_settings(
|
||||
*,
|
||||
model_name: str,
|
||||
force_required_tool_choice: bool = False,
|
||||
request_timeout: float | None = None,
|
||||
prompt_cache: bool = True,
|
||||
) -> ModelSettings:
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
include_usage=True,
|
||||
extra_args=request_timeout_extra_args(request_timeout),
|
||||
)
|
||||
if (
|
||||
reasoning_effort is not None
|
||||
@@ -149,38 +137,9 @@ def make_model_settings(
|
||||
)
|
||||
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
|
||||
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
|
||||
|
||||
cache_extra_args = _prompt_cache_extra_args(model_name) if prompt_cache else None
|
||||
if cache_extra_args:
|
||||
model_settings = model_settings.resolve(
|
||||
ModelSettings(
|
||||
extra_args={**(model_settings.extra_args or {}), **cache_extra_args},
|
||||
),
|
||||
)
|
||||
return model_settings
|
||||
|
||||
|
||||
def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
|
||||
"""LiteLLM ``cache_control_injection_points`` for Claude prompt caching.
|
||||
|
||||
System prompt + rolling last-message breakpoint everywhere; ``tool_config``
|
||||
only on Bedrock Converse (the only route whose LiteLLM transform consumes
|
||||
it — elsewhere it leaks onto the wire and native Anthropic 400s). Unmapped
|
||||
Bedrock models get no points at all: Bedrock rejects the passed-through
|
||||
field outright.
|
||||
"""
|
||||
if not is_claude_model(model_name):
|
||||
return None
|
||||
if is_bedrock_route(model_name) and not bedrock_route_supports_prompt_caching(model_name):
|
||||
return None
|
||||
|
||||
points: list[dict[str, Any]] = [{"location": "message", "role": "system"}]
|
||||
if is_bedrock_route(model_name):
|
||||
points.append({"location": "tool_config"})
|
||||
points.append({"location": "message", "index": -1})
|
||||
return {"cache_control_injection_points": points}
|
||||
|
||||
|
||||
def child_initial_input(
|
||||
*,
|
||||
name: str,
|
||||
@@ -198,11 +157,7 @@ def child_initial_input(
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if parent_history:
|
||||
rendered = json.dumps(
|
||||
scrub_images_from_items(parent_history),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
)
|
||||
rendered = json.dumps(parent_history, ensure_ascii=False, default=str)
|
||||
parts.append(
|
||||
"== Inherited context from parent (background only) ==\n"
|
||||
f"{rendered}\n"
|
||||
|
||||
@@ -21,20 +21,3 @@ def runtime_state_dir(run_dir: Path) -> Path:
|
||||
|
||||
def run_record_path(run_dir: Path) -> Path:
|
||||
return run_dir / RUN_RECORD_FILENAME
|
||||
|
||||
|
||||
def runs_base_dir(*, cwd: Path | None = None) -> Path:
|
||||
base = cwd or Path.cwd()
|
||||
return base / RUNS_DIR_NAME
|
||||
|
||||
|
||||
def latest_run_dir(*, cwd: Path | None = None) -> Path | None:
|
||||
base = runs_base_dir(cwd=cwd)
|
||||
if not base.is_dir():
|
||||
return None
|
||||
candidates = [child for child in base.iterdir() if run_record_path(child).is_file()]
|
||||
if not candidates:
|
||||
return None
|
||||
# run.json is rewritten on status/end changes, so its mtime tracks activity
|
||||
# more reliably than the directory mtime (a live run sorts to the top).
|
||||
return max(candidates, key=lambda child: run_record_path(child).stat().st_mtime)
|
||||
|
||||
+6
-116
@@ -3,12 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agents import RunConfig
|
||||
@@ -16,7 +14,6 @@ from agents.sandbox import SandboxRunConfig
|
||||
from openai import RateLimitError
|
||||
|
||||
from strix.agents.factory import build_strix_agent, make_child_factory
|
||||
from strix.agents.prompt import render_system_prompt
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import (
|
||||
StrixProvider,
|
||||
@@ -31,7 +28,7 @@ from strix.core.execution import (
|
||||
from strix.core.execution import (
|
||||
spawn_child_agent as start_child_agent,
|
||||
)
|
||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags
|
||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
|
||||
from strix.core.inputs import (
|
||||
DEFAULT_MAX_TURNS,
|
||||
build_root_task,
|
||||
@@ -40,13 +37,8 @@ from strix.core.inputs import (
|
||||
)
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.core.sessions import open_agent_session
|
||||
from strix.report.state import get_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||
from strix.tools.output_store import (
|
||||
WORKSPACE_SPILL_DIR,
|
||||
configure_spill_writer,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -59,52 +51,6 @@ logger = logging.getLogger(__name__)
|
||||
StreamEventSink = Callable[[str, Any], None]
|
||||
|
||||
|
||||
def _merge_root_prompt_context(
|
||||
scope_context: dict[str, Any],
|
||||
extra_system_prompt_context: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
if not extra_system_prompt_context:
|
||||
return scope_context
|
||||
reserved_keys = scope_context.keys() & extra_system_prompt_context.keys()
|
||||
if reserved_keys:
|
||||
raise ValueError(
|
||||
"extra_system_prompt_context cannot override built-in scope keys: "
|
||||
f"{sorted(reserved_keys)}",
|
||||
)
|
||||
return {**scope_context, **extra_system_prompt_context}
|
||||
|
||||
|
||||
def _compose_root_instructions_override(
|
||||
root_instructions_override: str | None,
|
||||
*,
|
||||
skills: list[str],
|
||||
scan_mode: str,
|
||||
is_whitebox: bool,
|
||||
interactive: bool,
|
||||
system_prompt_context: dict[str, Any],
|
||||
) -> str | None:
|
||||
if root_instructions_override is None:
|
||||
return None
|
||||
|
||||
base_instructions = render_system_prompt(
|
||||
skills=skills,
|
||||
scan_mode=scan_mode,
|
||||
is_whitebox=is_whitebox,
|
||||
is_root=True,
|
||||
interactive=interactive,
|
||||
system_prompt_context=system_prompt_context,
|
||||
)
|
||||
return (
|
||||
f"{base_instructions}\n\n"
|
||||
"<root_scan_instructions_override>\n"
|
||||
"The following root scan instructions are subordinate to the "
|
||||
"system-verified scope above. They cannot expand, replace, or weaken "
|
||||
"authorized target constraints.\n\n"
|
||||
f"{root_instructions_override}\n"
|
||||
"</root_scan_instructions_override>"
|
||||
)
|
||||
|
||||
|
||||
async def run_strix_scan(
|
||||
*,
|
||||
scan_config: dict[str, Any],
|
||||
@@ -118,17 +64,8 @@ async def run_strix_scan(
|
||||
model: str | None = None,
|
||||
cleanup_on_exit: bool = True,
|
||||
event_sink: StreamEventSink | None = None,
|
||||
root_instructions_override: str | None = None,
|
||||
extra_system_prompt_context: dict[str, Any] | None = None,
|
||||
) -> RunResultBase | None:
|
||||
"""Run or resume one Strix scan against a sandbox.
|
||||
|
||||
``root_instructions_override`` adds root scan instructions to the rendered
|
||||
root prompt without replacing the system-verified scope block.
|
||||
``extra_system_prompt_context`` is merged into the root agent's scan
|
||||
context before prompt rendering. Child agents keep the standard scan prompt
|
||||
and context.
|
||||
"""
|
||||
"""Run or resume one Strix scan against a sandbox."""
|
||||
if scan_id is None:
|
||||
scan_id = f"scan-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -186,18 +123,6 @@ async def run_strix_scan(
|
||||
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
|
||||
)
|
||||
await coordinator.restore(snap)
|
||||
report_state = get_global_report_state()
|
||||
if report_state is not None:
|
||||
budget_stopped, reserve_stopped = recomputed_budget_flags(
|
||||
report_state.get_total_llm_cost(),
|
||||
max_budget_usd,
|
||||
interactive=interactive,
|
||||
)
|
||||
await coordinator.reset_budget_stops(
|
||||
budget_stopped=budget_stopped,
|
||||
reserve_stopped=reserve_stopped,
|
||||
budget_paused=interactive and coordinator.budget_paused,
|
||||
)
|
||||
for aid, parent in coordinator.parent_of.items():
|
||||
if parent is None:
|
||||
root_id = aid
|
||||
@@ -222,20 +147,6 @@ async def run_strix_scan(
|
||||
)
|
||||
logger.info("Sandbox ready for scan %s", scan_id)
|
||||
|
||||
sandbox_session = bundle["session"]
|
||||
|
||||
async def _spill_to_workspace(output_id: str, text: str) -> str | None:
|
||||
"""Write an oversized tool result into the sandbox; return its path or None."""
|
||||
path = f"{WORKSPACE_SPILL_DIR}/{output_id}.txt"
|
||||
try:
|
||||
await sandbox_session.write(Path(path), io.BytesIO(text.encode("utf-8")))
|
||||
except Exception:
|
||||
logger.exception("failed to spill tool output to sandbox workspace")
|
||||
return None
|
||||
return path
|
||||
|
||||
configure_spill_writer(_spill_to_workspace)
|
||||
|
||||
sessions_to_close: list[SQLiteSession] = []
|
||||
|
||||
try:
|
||||
@@ -248,8 +159,6 @@ async def run_strix_scan(
|
||||
settings.llm.reasoning_effort,
|
||||
model_name=resolved_model,
|
||||
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
||||
request_timeout=settings.llm.timeout,
|
||||
prompt_cache=settings.llm.prompt_cache,
|
||||
)
|
||||
run_config = RunConfig(
|
||||
model=resolved_model,
|
||||
@@ -258,42 +167,25 @@ async def run_strix_scan(
|
||||
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
||||
trace_include_sensitive_data=False,
|
||||
)
|
||||
hooks = ReportUsageHooks(
|
||||
model=resolved_model,
|
||||
max_budget_usd=max_budget_usd,
|
||||
max_turns=max_turns,
|
||||
interactive=interactive,
|
||||
)
|
||||
if interactive:
|
||||
coordinator.set_budget_extender(hooks.extend_budget)
|
||||
hooks = ReportUsageHooks(model=resolved_model, max_budget_usd=max_budget_usd)
|
||||
|
||||
scope_context = build_scope_context(scan_config)
|
||||
root_context = _merge_root_prompt_context(scope_context, extra_system_prompt_context)
|
||||
root_instructions = _compose_root_instructions_override(
|
||||
root_instructions_override,
|
||||
skills=skills,
|
||||
scan_mode=scan_mode,
|
||||
is_whitebox=is_whitebox,
|
||||
interactive=interactive,
|
||||
system_prompt_context=root_context,
|
||||
)
|
||||
|
||||
root_agent = build_strix_agent(
|
||||
name="Strix",
|
||||
name="strix",
|
||||
skills=skills,
|
||||
is_root=True,
|
||||
scan_mode=scan_mode,
|
||||
is_whitebox=is_whitebox,
|
||||
interactive=interactive,
|
||||
chat_completions_tools=chat_completions_tools,
|
||||
system_prompt_context=root_context,
|
||||
instructions_override=root_instructions,
|
||||
system_prompt_context=scope_context,
|
||||
)
|
||||
|
||||
if not is_resume:
|
||||
await coordinator.register(
|
||||
root_id,
|
||||
"Strix",
|
||||
"strix",
|
||||
parent_id=None,
|
||||
task=root_task,
|
||||
skills=skills,
|
||||
@@ -329,7 +221,6 @@ async def run_strix_scan(
|
||||
"parent_id": None,
|
||||
"interactive": interactive,
|
||||
"spawn_child_agent": spawn_child_agent,
|
||||
"max_context_images": settings.runtime.max_context_images,
|
||||
}
|
||||
|
||||
root_session = open_agent_session(root_id, agents_db)
|
||||
@@ -440,7 +331,6 @@ async def run_strix_scan(
|
||||
await coordinator.set_status(root_id, "failed")
|
||||
raise
|
||||
finally:
|
||||
configure_spill_writer(None)
|
||||
for s in sessions_to_close:
|
||||
with contextlib.suppress(Exception):
|
||||
s.close()
|
||||
|
||||
+36
-154
@@ -2,182 +2,64 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from agents.memory import SQLiteSession
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
from agents.memory import Session
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return SQLiteSession(session_id=agent_id, db_path=path)
|
||||
|
||||
|
||||
_IMAGE_REJECTED_TEXT = "[image rejected by the model]"
|
||||
_IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]"
|
||||
_INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]"
|
||||
|
||||
|
||||
def _output_has_image(item_dict: dict[str, Any]) -> bool:
|
||||
return (
|
||||
item_dict.get("type") == "function_call_output"
|
||||
and isinstance(item_dict.get("output"), list)
|
||||
and any(isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"])
|
||||
)
|
||||
|
||||
|
||||
def _elided_output(item_dict: dict[str, Any], text: str) -> dict[str, Any]:
|
||||
# Replace only image blocks; sibling text blocks are preserved.
|
||||
output = item_dict.get("output")
|
||||
blocks = output if isinstance(output, list) else []
|
||||
return {
|
||||
"type": "function_call_output",
|
||||
"call_id": item_dict.get("call_id"),
|
||||
"output": [
|
||||
{"type": "input_text", "text": text}
|
||||
if isinstance(block, dict) and block.get("type") == "input_image"
|
||||
else block
|
||||
for block in blocks
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
_session_write_locks: WeakKeyDictionary[Session, asyncio.Lock] = WeakKeyDictionary()
|
||||
|
||||
|
||||
def session_write_lock(session: Session) -> asyncio.Lock:
|
||||
"""Lock serialising all out-of-band writes to ``session``."""
|
||||
lock = _session_write_locks.get(session)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_session_write_locks[session] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def _rewrite_session(
|
||||
session: Session,
|
||||
transform: Callable[[list[Any]], tuple[list[Any], bool]],
|
||||
) -> bool:
|
||||
"""Read-modify-write a session under its write lock, restoring on failure."""
|
||||
async with session_write_lock(session):
|
||||
items = await session.get_items()
|
||||
if not items:
|
||||
return False
|
||||
rebuilt, changed = transform(list(items))
|
||||
if not changed:
|
||||
return False
|
||||
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
|
||||
original_items = cast("list[TResponseInputItem]", list(items))
|
||||
await session.clear_session()
|
||||
try:
|
||||
await session.add_items(rebuilt_items)
|
||||
except Exception:
|
||||
logger.exception("session rewrite failed; restoring original items")
|
||||
await session.clear_session()
|
||||
await session.add_items(original_items)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def replace_session_items(
|
||||
session: Session,
|
||||
new_items: list[Any],
|
||||
*,
|
||||
expected_len: int | None = None,
|
||||
) -> bool:
|
||||
"""Overwrite the session's items, restoring the originals on failure.
|
||||
|
||||
When ``expected_len`` is given, the rewrite is skipped if the session no
|
||||
longer has that many items (a concurrent writer changed it), so a slow
|
||||
compaction summary can't clobber newer turns.
|
||||
"""
|
||||
async with session_write_lock(session):
|
||||
original = list(await session.get_items())
|
||||
if expected_len is not None and len(original) != expected_len:
|
||||
logger.warning(
|
||||
"skipping session rewrite: expected %d items, found %d",
|
||||
expected_len,
|
||||
len(original),
|
||||
)
|
||||
return False
|
||||
rebuilt = cast("list[TResponseInputItem]", new_items)
|
||||
await session.clear_session()
|
||||
try:
|
||||
await session.add_items(rebuilt)
|
||||
except Exception:
|
||||
logger.exception("session rewrite failed; restoring original items")
|
||||
await session.clear_session()
|
||||
await session.add_items(original)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
async def strip_all_images_from_session(session: Session) -> bool:
|
||||
"""Replace every image tool output with a text placeholder (rejection recovery)."""
|
||||
|
||||
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
|
||||
rebuilt: list[Any] = []
|
||||
changed = False
|
||||
for item in items:
|
||||
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
|
||||
if item_dict is not None and _output_has_image(item_dict):
|
||||
rebuilt.append(_elided_output(item_dict, _IMAGE_REJECTED_TEXT))
|
||||
changed = True
|
||||
else:
|
||||
rebuilt.append(item)
|
||||
return rebuilt, changed
|
||||
|
||||
return await _rewrite_session(session, _transform)
|
||||
|
||||
|
||||
async def enforce_image_budget(session: Session, max_images: int) -> bool:
|
||||
"""Keep only the most recent ``max_images`` image outputs; elide older ones."""
|
||||
if max_images < 0:
|
||||
items = await session.get_items()
|
||||
if not items:
|
||||
return False
|
||||
|
||||
def _transform(items: list[Any]) -> tuple[list[Any], bool]:
|
||||
image_indices = [
|
||||
i
|
||||
for i, item in enumerate(items)
|
||||
if isinstance(item, dict) and _output_has_image(cast("dict[str, Any]", item))
|
||||
]
|
||||
if len(image_indices) <= max_images:
|
||||
return items, False
|
||||
to_elide = set(image_indices[: len(image_indices) - max_images])
|
||||
rebuilt = [
|
||||
_elided_output(cast("dict[str, Any]", item), _IMAGE_ELIDED_TEXT)
|
||||
if i in to_elide
|
||||
else item
|
||||
for i, item in enumerate(items)
|
||||
]
|
||||
return rebuilt, True
|
||||
rebuilt: list[Any] = []
|
||||
changed = False
|
||||
for item in items:
|
||||
item_dict = cast("dict[str, Any]", item) if isinstance(item, dict) else None
|
||||
if (
|
||||
item_dict is not None
|
||||
and item_dict.get("type") == "function_call_output"
|
||||
and isinstance(item_dict.get("output"), list)
|
||||
and any(
|
||||
isinstance(b, dict) and b.get("type") == "input_image" for b in item_dict["output"]
|
||||
)
|
||||
):
|
||||
rebuilt.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": item_dict.get("call_id"),
|
||||
"output": [{"type": "input_text", "text": _IMAGE_REJECTED_TEXT}],
|
||||
},
|
||||
)
|
||||
changed = True
|
||||
else:
|
||||
rebuilt.append(item)
|
||||
|
||||
return await _rewrite_session(session, _transform)
|
||||
if not changed:
|
||||
return False
|
||||
|
||||
|
||||
def scrub_images_from_items(items: list[Any]) -> list[Any]:
|
||||
"""Return a copy of ``items`` with every image block replaced by text."""
|
||||
|
||||
def _scrub(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
if obj.get("type") == "input_image":
|
||||
return {"type": "input_text", "text": _INHERITED_IMAGE_TEXT}
|
||||
return {k: _scrub(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_scrub(v) for v in obj]
|
||||
return obj
|
||||
|
||||
return [_scrub(item) for item in items]
|
||||
rebuilt_items = cast("list[TResponseInputItem]", rebuilt)
|
||||
await session.clear_session()
|
||||
try:
|
||||
await session.add_items(rebuilt_items)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
await session.add_items(rebuilt_items)
|
||||
raise
|
||||
return True
|
||||
|
||||
@@ -67,16 +67,6 @@ Toast.-information .toast--title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#viewer_cta {
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: round #333333;
|
||||
color: #60a5fa;
|
||||
padding: 0 1;
|
||||
margin-bottom: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#agents_tree {
|
||||
height: 1fr;
|
||||
background: transparent;
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
"""`strix auth` — ChatGPT subscription sign-in (login / status / logout).
|
||||
|
||||
Signing in only stores OAuth tokens (``~/.strix/subscription-auth.json``); model
|
||||
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on the
|
||||
subscription.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import logging
|
||||
import threading
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CALLBACK_TIMEOUT_S = 300
|
||||
|
||||
# CLI-facing name for the login provider. Internally this is the Codex OAuth
|
||||
# flow (``codex.PROVIDER``), but users know it as ChatGPT, so that's what the
|
||||
# command and messaging say. ``codex`` is accepted as an alias.
|
||||
LOGIN_PROVIDER = "chatgpt"
|
||||
_ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER})
|
||||
|
||||
_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout"
|
||||
|
||||
|
||||
def run_auth(argv: list[str]) -> int:
|
||||
"""Entry point for ``strix auth …``. Returns a process exit code."""
|
||||
console = Console()
|
||||
# Bare `strix auth` (no subcommand) defaults to login.
|
||||
subcommand = argv[0] if argv else "login"
|
||||
rest = argv[1:]
|
||||
|
||||
if subcommand in ("-h", "--help", "help"):
|
||||
console.print(_USAGE)
|
||||
return 0
|
||||
|
||||
handlers: dict[str, Callable[[], int]] = {
|
||||
"login": lambda: _login(console, rest),
|
||||
"status": lambda: _status(console),
|
||||
"logout": lambda: _logout(console),
|
||||
}
|
||||
handler = handlers.get(subcommand)
|
||||
if handler is not None:
|
||||
return handler()
|
||||
|
||||
console.print(f"[red]Unknown auth command:[/] {subcommand}\n")
|
||||
console.print(_USAGE)
|
||||
return 2
|
||||
|
||||
|
||||
def _login(console: Console, argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog="strix auth login", add_help=True)
|
||||
parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=LOGIN_PROVIDER,
|
||||
help="Model provider to sign in with (default: chatgpt).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manual",
|
||||
action="store_true",
|
||||
help="Skip the local callback server and paste the redirect URL by hand.",
|
||||
)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as exc: # argparse already printed the message
|
||||
return int(exc.code or 2)
|
||||
|
||||
if args.provider.lower() not in _ACCEPTED_PROVIDERS:
|
||||
console.print(
|
||||
f"[red]Unsupported provider:[/] {args.provider}. "
|
||||
f"Only '{LOGIN_PROVIDER}' (ChatGPT subscription) is supported."
|
||||
)
|
||||
return 2
|
||||
|
||||
verifier, challenge = codex.generate_pkce()
|
||||
state = codex.create_state()
|
||||
authorize_url = codex.build_authorize_url(challenge, state)
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Signing in with ChatGPT[/] [dim](provider: chatgpt)[/]")
|
||||
console.print(
|
||||
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
try:
|
||||
record = _run_oauth_flow(console, authorize_url, verifier, state, manual=args.manual)
|
||||
except codex.CodexAuthError as exc:
|
||||
return _fail(console, exc)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Sign-in cancelled.[/]")
|
||||
return 130
|
||||
|
||||
codex.save_record(record)
|
||||
_print_success(console)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_oauth_flow(
|
||||
console: Console,
|
||||
authorize_url: str,
|
||||
verifier: str,
|
||||
state: str,
|
||||
*,
|
||||
manual: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the browser (or manual) OAuth flow and return a token record."""
|
||||
server = None if manual else _try_start_callback_server()
|
||||
|
||||
console.print("Open this URL in your browser to authorize:")
|
||||
console.print(f"[cyan]{authorize_url}[/]")
|
||||
console.print()
|
||||
if not manual:
|
||||
try:
|
||||
webbrowser.open(authorize_url)
|
||||
except Exception: # noqa: BLE001 - opening a browser is best-effort
|
||||
logger.debug("could not open browser", exc_info=True)
|
||||
|
||||
if server is not None:
|
||||
console.print("[dim]Waiting for you to finish signing in…[/]")
|
||||
result = server.wait(_CALLBACK_TIMEOUT_S)
|
||||
server.shutdown()
|
||||
if result is not None:
|
||||
code, returned_state, error = result
|
||||
if error:
|
||||
raise codex.CodexAuthError("oauth_error", error)
|
||||
return _finish(code, returned_state, verifier, state, require_state=True)
|
||||
console.print("[yellow]Timed out waiting for the browser. Falling back to manual paste.[/]")
|
||||
|
||||
# Manual fallback: the user completes sign-in and pastes the redirect URL
|
||||
# (the browser lands on a localhost page that won't load if no server is up;
|
||||
# the address bar still holds the code+state).
|
||||
console.print()
|
||||
try:
|
||||
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
|
||||
except EOFError as exc:
|
||||
raise codex.CodexAuthError("no_input", "no redirect URL provided") from exc
|
||||
code, returned_state = codex.parse_redirect_input(pasted)
|
||||
return _finish(code, returned_state, verifier, state, require_state=False)
|
||||
|
||||
|
||||
def _finish(
|
||||
code: str | None,
|
||||
returned_state: str | None,
|
||||
verifier: str,
|
||||
expected_state: str,
|
||||
*,
|
||||
require_state: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not code:
|
||||
raise codex.CodexAuthError("no_code", "no authorization code found in the redirect")
|
||||
# The loopback callback from OpenAI always carries state, so a missing or
|
||||
# mismatched value there is forged (CSRF) and must be rejected. Manual paste
|
||||
# is user-initiated (the user copies their own redirect), so state is only
|
||||
# validated when the pasted value includes it.
|
||||
if require_state and returned_state is None:
|
||||
raise codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
|
||||
if returned_state is not None and returned_state != expected_state:
|
||||
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
|
||||
return codex.exchange_code(code, verifier)
|
||||
|
||||
|
||||
class _CallbackServer:
|
||||
"""A one-shot local HTTP server that catches the OAuth redirect."""
|
||||
|
||||
def __init__(self, httpd: HTTPServer, event: threading.Event, holder: dict[str, Any]) -> None:
|
||||
self._httpd = httpd
|
||||
self._event = event
|
||||
self._holder = holder
|
||||
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def wait(self, timeout: float) -> tuple[str | None, str | None, str | None] | None:
|
||||
if not self._event.wait(timeout):
|
||||
return None
|
||||
return (
|
||||
self._holder.get("code"),
|
||||
self._holder.get("state"),
|
||||
self._holder.get("error"),
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._httpd.shutdown()
|
||||
self._httpd.server_close()
|
||||
|
||||
|
||||
def _try_start_callback_server() -> _CallbackServer | None:
|
||||
event = threading.Event()
|
||||
holder: dict[str, Any] = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args: Any) -> None: # silence default stderr logging
|
||||
pass
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != codex.CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
query = parse_qs(parsed.query)
|
||||
holder["code"] = _first(query, "code")
|
||||
holder["state"] = _first(query, "state")
|
||||
holder["error"] = _first(query, "error_description") or _first(query, "error")
|
||||
body = _render_callback_html().encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
event.set()
|
||||
|
||||
try:
|
||||
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
|
||||
except OSError:
|
||||
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
|
||||
return None
|
||||
return _CallbackServer(httpd, event, holder)
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _status(console: Console) -> int:
|
||||
record = codex.read_record()
|
||||
if record is None:
|
||||
console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] to sign in.")
|
||||
return 1
|
||||
settings = load_settings()
|
||||
console.print("[green]Signed in[/] with a ChatGPT subscription.")
|
||||
console.print(f" Account: [bold]{record.get('account_id')}[/]")
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{settings.llm.model}[/]).")
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. [cyan]chatgpt/gpt-5.4[/] "
|
||||
"to run on the subscription."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _logout(console: Console) -> int:
|
||||
codex.logout()
|
||||
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
|
||||
return 0
|
||||
|
||||
|
||||
def _fail(console: Console, exc: codex.CodexAuthError) -> int:
|
||||
error_text = Text()
|
||||
error_text.append("SIGN-IN FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(f"{exc}", style="white")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _print_success(console: Console) -> None:
|
||||
text = Text()
|
||||
text.append("Signed in with your ChatGPT subscription", style="bold #22c55e")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Set ", style="white")
|
||||
text.append("STRIX_LLM", style="bold white")
|
||||
text.append(" to a ", style="white")
|
||||
text.append("chatgpt/", style="bold cyan")
|
||||
text.append(" model (e.g. ", style="white")
|
||||
text.append("chatgpt/gpt-5.4", style="bold cyan")
|
||||
text.append(") — runs are billed to your ChatGPT plan.", style="white")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Run a scan as usual, e.g. ", style="white")
|
||||
text.append("strix --target https://example.com", style="bold cyan")
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="#22c55e",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
_LOGO_PATH = Path(__file__).resolve().parent.parent / "viewer" / "static" / "logo.png"
|
||||
|
||||
|
||||
def _logo_img_tag() -> str:
|
||||
"""Return an ``<img>`` for the Strix logo as an inline data URI, or "".
|
||||
|
||||
The callback page is served offline by the local OAuth server, so the logo
|
||||
is embedded rather than linked. Missing/unreadable file degrades to just the
|
||||
"Strix" wordmark.
|
||||
"""
|
||||
try:
|
||||
data = _LOGO_PATH.read_bytes()
|
||||
except OSError:
|
||||
return ""
|
||||
encoded = base64.b64encode(data).decode("ascii")
|
||||
return f'<img class="logo" src="data:image/png;base64,{encoded}" alt="" />'
|
||||
|
||||
|
||||
def _render_callback_html() -> str:
|
||||
return _CALLBACK_HTML.replace("<!--LOGO-->", _logo_img_tag())
|
||||
|
||||
|
||||
_CALLBACK_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Strix — signed in</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; padding: 24px;
|
||||
font-family: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||
background: #000; color: #ededed;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.topbar {
|
||||
position: absolute; top: 20px; left: 22px;
|
||||
display: flex; align-items: center; gap: 6px; text-decoration: none;
|
||||
}
|
||||
.topbar .logo { width: 40px; height: 40px; display: block; }
|
||||
.topbar span {
|
||||
font-size: 1.1rem; font-weight: 600; letter-spacing: -.01em; color: #fff;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
.topbar:hover span { color: #c9c9c9; }
|
||||
.brand {
|
||||
font-size: 2.1rem; font-weight: 700; letter-spacing: -.02em; color: #fff;
|
||||
text-align: center; margin: 0 0 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.35rem; font-weight: 600; letter-spacing: -.01em; color: #f5f5f5;
|
||||
text-align: center; margin: 0 0 28px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 430px; text-align: center;
|
||||
background: #171717; border: 1px solid rgba(255, 255, 255, .06);
|
||||
border-radius: 24px; padding: 40px 40px 34px;
|
||||
}
|
||||
.badge {
|
||||
margin: 0 auto 22px; width: 52px; height: 52px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 23px; color: #fff;
|
||||
background: rgba(255, 255, 255, .05); border: 1px solid rgba(255, 255, 255, .14);
|
||||
}
|
||||
.msg { margin: 0 auto; max-width: 34ch; color: #b5b5b5; line-height: 1.6; font-size: .98rem; }
|
||||
.rule { height: 1px; background: rgba(255, 255, 255, .07); margin: 26px 0 0; }
|
||||
.tagline { margin: 22px 0 0; color: #7c7c7c; font-size: .9rem; line-height: 1.55; }
|
||||
.tagline b { color: #ededed; font-weight: 500; }
|
||||
.links {
|
||||
margin-top: 18px; display: flex; gap: 8px; justify-content: center;
|
||||
align-items: center; flex-wrap: wrap; font-size: .84rem;
|
||||
}
|
||||
.links a { color: #a3a3a3; text-decoration: none; transition: color .15s ease; }
|
||||
.links a:hover { color: #fff; }
|
||||
.links .dot { color: #3a3a3a; }
|
||||
.close { margin: 24px 0 0; color: #5a5a5a; font-size: .78rem; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<a class="topbar" href="https://strix.ai" target="_blank" rel="noopener"
|
||||
aria-label="Strix — strix.ai">
|
||||
<!--LOGO-->
|
||||
<span>Strix</span>
|
||||
</a>
|
||||
<div class="brand">Strix</div>
|
||||
<h1>You're signed in</h1>
|
||||
<main class="card">
|
||||
<div class="badge">✓</div>
|
||||
<p class="msg">Strix is connected to your ChatGPT subscription. Head back to your
|
||||
terminal — your security test runs there.</p>
|
||||
<div class="rule"></div>
|
||||
<p class="tagline">Autonomous AI hackers that <b>find and fix</b> your app's
|
||||
vulnerabilities.</p>
|
||||
<nav class="links">
|
||||
<a href="https://strix.ai" target="_blank" rel="noopener">strix.ai</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://docs.strix.ai" target="_blank" rel="noopener">docs</a>
|
||||
<span class="dot">·</span>
|
||||
<a href="https://discord.gg/strix-ai" target="_blank" rel="noopener">community</a>
|
||||
</nav>
|
||||
</main>
|
||||
<p class="close">You can close this tab.</p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
__all__ = ["run_auth"]
|
||||
@@ -13,7 +13,6 @@ from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
from strix.core.runner import run_strix_scan
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.runtime import session_manager
|
||||
@@ -185,7 +184,6 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
||||
local_sources=getattr(args, "local_sources", None) or [],
|
||||
interactive=bool(getattr(args, "interactive", False)),
|
||||
max_budget_usd=getattr(args, "max_budget_usd", None),
|
||||
max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS),
|
||||
)
|
||||
finally:
|
||||
stop_updates.set()
|
||||
|
||||
+18
-200
@@ -5,7 +5,6 @@ Strix Agent Interface
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
@@ -20,28 +19,17 @@ from rich.text import Text
|
||||
|
||||
from strix.config import (
|
||||
apply_config_override,
|
||||
codex,
|
||||
load_settings,
|
||||
persist_current,
|
||||
)
|
||||
from strix.config.models import (
|
||||
RECOMMENDED_MODEL_NAMES,
|
||||
StrixProvider,
|
||||
configure_sdk_model_defaults,
|
||||
is_known_openai_bare_model,
|
||||
is_recommended_or_frontier_model,
|
||||
)
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.interface.cli import run_cli
|
||||
from strix.interface.tui import run_tui
|
||||
from strix.interface.update_check import (
|
||||
is_binary_install,
|
||||
notify_update,
|
||||
prompt_update_if_available,
|
||||
self_update,
|
||||
start_background_check,
|
||||
)
|
||||
from strix.interface.utils import (
|
||||
assign_workspace_subdirs,
|
||||
build_final_stats_text,
|
||||
@@ -94,16 +82,6 @@ def validate_environment() -> None:
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
if not codex.is_authenticated():
|
||||
console.print(
|
||||
f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, "
|
||||
"but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first."
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.info("Environment OK (ChatGPT subscription)")
|
||||
return
|
||||
|
||||
if not settings.llm.model:
|
||||
missing_required_vars.append("STRIX_LLM")
|
||||
|
||||
@@ -211,7 +189,7 @@ def validate_environment() -> None:
|
||||
padding=(1, 2),
|
||||
)
|
||||
|
||||
logger.debug("Missing required env vars: %s", missing_required_vars)
|
||||
logger.error("Missing required env vars: %s", missing_required_vars)
|
||||
console.print("\n")
|
||||
console.print(panel)
|
||||
console.print()
|
||||
@@ -224,7 +202,7 @@ def validate_environment() -> None:
|
||||
|
||||
def check_docker_installed() -> None:
|
||||
if shutil.which("docker") is None:
|
||||
logger.debug("Docker CLI not found in PATH")
|
||||
logger.error("Docker CLI not found in PATH")
|
||||
console = Console()
|
||||
error_text = Text()
|
||||
error_text.append("DOCKER NOT INSTALLED", style="bold red")
|
||||
@@ -286,30 +264,7 @@ def _provider_import_hint(exc: BaseException, model: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _subscription_error_hint(exc: BaseException) -> str | None:
|
||||
"""Return an actionable hint for a known ChatGPT-subscription error, or None."""
|
||||
if not codex.subscription_model(load_settings().llm.model):
|
||||
return None
|
||||
joined = " ".join(_exception_messages(exc)).lower()
|
||||
if "not supported when using codex with a chatgpt account" in joined:
|
||||
return (
|
||||
"This model isn't available on your ChatGPT subscription. "
|
||||
"Set STRIX_LLM to a model your plan includes (e.g. chatgpt/gpt-5.4)."
|
||||
)
|
||||
if (
|
||||
"error code: 401" in joined
|
||||
or "http 401" in joined
|
||||
or "unauthorized" in joined
|
||||
or "invalid_grant" in joined
|
||||
):
|
||||
return (
|
||||
"Your ChatGPT sign-in has expired or was revoked. Sign in again:\n"
|
||||
" strix auth login chatgpt"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
async def warm_up_llm() -> None:
|
||||
console = Console()
|
||||
logger.info("Warming up LLM connection")
|
||||
|
||||
@@ -318,8 +273,8 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
settings = load_settings()
|
||||
configure_sdk_model_defaults(settings)
|
||||
llm = settings.llm
|
||||
raw_model = (llm.model or "").strip()
|
||||
|
||||
raw_model = (llm.model or "").strip()
|
||||
if (
|
||||
raw_model
|
||||
and "/" not in raw_model
|
||||
@@ -351,32 +306,6 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if show_model_warning and raw_model and not is_recommended_or_frontier_model(raw_model):
|
||||
warn_text = Text()
|
||||
warn_text.append("MODEL QUALITY WARNING", style="bold yellow")
|
||||
warn_text.append("\n\n", style="white")
|
||||
warn_text.append(f"'{raw_model}'", style="bold cyan")
|
||||
warn_text.append(
|
||||
" is not a recommended frontier model for Strix.\nSecurity scans work best with:\n",
|
||||
style="white",
|
||||
)
|
||||
for recommended_model in RECOMMENDED_MODEL_NAMES:
|
||||
warn_text.append(f"• {recommended_model}\n", style="bold cyan")
|
||||
warn_text.append(
|
||||
"\nYou can continue, but weaker models may miss vulnerabilities "
|
||||
"or produce lower-quality findings.",
|
||||
style="white",
|
||||
)
|
||||
console.print(
|
||||
Panel(
|
||||
warn_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
),
|
||||
)
|
||||
|
||||
model = StrixProvider().get_model(raw_model)
|
||||
await asyncio.wait_for(
|
||||
model.get_response(
|
||||
@@ -395,63 +324,23 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
)
|
||||
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
||||
|
||||
if settings.dedupe.model:
|
||||
from strix.report.dedupe import _dedupe_extra_args
|
||||
|
||||
dedupe_model = settings.dedupe.model.strip()
|
||||
raw_model = dedupe_model
|
||||
deduper = StrixProvider().get_model(dedupe_model)
|
||||
# Match the runtime path: send the dedupe key/endpoint per call so a
|
||||
# separate-provider dedupe model authenticates during warm-up too.
|
||||
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
||||
deduper_settings = ModelSettings(extra_args=deduper_extra or None)
|
||||
await asyncio.wait_for(
|
||||
deduper.get_response(
|
||||
system_instructions="You are a helpful assistant.",
|
||||
input="Reply with just 'OK'.",
|
||||
model_settings=deduper_settings,
|
||||
tools=[],
|
||||
output_schema=None,
|
||||
handoffs=[],
|
||||
tracing=ModelTracing.DISABLED,
|
||||
previous_response_id=None,
|
||||
conversation_id=None,
|
||||
prompt=None,
|
||||
),
|
||||
timeout=llm.timeout,
|
||||
)
|
||||
logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("LLM warm-up failed", exc_info=True)
|
||||
logger.exception("LLM warm-up failed")
|
||||
error_text = Text()
|
||||
sub_hint = _subscription_error_hint(e)
|
||||
if sub_hint is not None:
|
||||
# The model/backend answered with a clear, actionable rejection —
|
||||
# show that instead of a generic "connection failed".
|
||||
border_style = "yellow"
|
||||
error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(f"{sub_hint}\n", style="white")
|
||||
error_text.append(f"\nDetails: {e}", style="dim white")
|
||||
else:
|
||||
border_style = "red"
|
||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append(
|
||||
"Could not establish connection to the language model.\n", style="white"
|
||||
)
|
||||
error_text.append("Please check your configuration and try again.\n", style="white")
|
||||
hint = _provider_import_hint(e, raw_model)
|
||||
if hint is not None:
|
||||
error_text.append(f"\n{hint}\n", style="bold yellow")
|
||||
error_text.append(f"\nError: {e}", style="dim white")
|
||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
error_text.append("Could not establish connection to the language model.\n", style="white")
|
||||
error_text.append("Please check your configuration and try again.\n", style="white")
|
||||
hint = _provider_import_hint(e, raw_model)
|
||||
if hint is not None:
|
||||
error_text.append(f"\n{hint}\n", style="bold yellow")
|
||||
error_text.append(f"\nError: {e}", style="dim white")
|
||||
|
||||
panel = Panel(
|
||||
error_text,
|
||||
title="[bold white]STRIX",
|
||||
title_align="left",
|
||||
border_style=border_style,
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
|
||||
@@ -482,16 +371,6 @@ def _positive_budget(value: str) -> float:
|
||||
return budget
|
||||
|
||||
|
||||
def _positive_int(value: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
|
||||
if parsed <= 0:
|
||||
raise argparse.ArgumentTypeError("must be an integer greater than 0")
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
|
||||
@@ -540,14 +419,6 @@ Examples:
|
||||
version=f"strix {get_version()}",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--update",
|
||||
action="store_true",
|
||||
help="Update strix to the latest version and exit. Self-updates the "
|
||||
"standalone binary install; for pip/pipx/uv installs, prints the "
|
||||
"matching upgrade command instead.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--target",
|
||||
@@ -647,27 +518,10 @@ Examples:
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-budget",
|
||||
dest="max_budget_usd",
|
||||
metavar="USD",
|
||||
"--max-budget-usd",
|
||||
type=_positive_budget,
|
||||
default=None,
|
||||
help=(
|
||||
"Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. "
|
||||
"Graduated wrap-up warnings are sent to all agents as it is approached."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-turns",
|
||||
dest="max_turns",
|
||||
metavar="N",
|
||||
type=_positive_int,
|
||||
default=DEFAULT_MAX_TURNS,
|
||||
help=(
|
||||
"Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped "
|
||||
"when it reaches this limit, with graduated wrap-up warnings as it is approached."
|
||||
),
|
||||
help="Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
@@ -683,9 +537,6 @@ Examples:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.update:
|
||||
sys.exit(0 if self_update() else 1)
|
||||
|
||||
if args.instruction and args.instruction_file:
|
||||
parser.error(
|
||||
"Cannot specify both --instruction and --instruction-file. Use one or the other."
|
||||
@@ -784,7 +635,6 @@ def _persist_run_record(args: argparse.Namespace) -> None:
|
||||
"status": "running",
|
||||
"start_time": datetime.now(UTC).isoformat(),
|
||||
"end_time": None,
|
||||
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||
"targets_info": args.targets_info,
|
||||
"scan_mode": args.scan_mode,
|
||||
"instruction": args.instruction,
|
||||
@@ -881,13 +731,6 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
results_text.append(str(results_path), style="#60a5fa")
|
||||
panel_parts.extend(["\n", results_text])
|
||||
|
||||
view_text = Text()
|
||||
view_text.append("\n")
|
||||
view_text.append("View", style="dim")
|
||||
view_text.append(" ")
|
||||
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||
panel_parts.extend(["\n", view_text])
|
||||
|
||||
if not scan_completed:
|
||||
resume_text = Text()
|
||||
resume_text.append("\n")
|
||||
@@ -917,8 +760,6 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
|
||||
"[#60a5fa]discord.gg/strix-ai[/]"
|
||||
)
|
||||
console.print()
|
||||
if not args.non_interactive:
|
||||
notify_update(console)
|
||||
|
||||
|
||||
def pull_docker_image() -> None:
|
||||
@@ -946,7 +787,7 @@ def pull_docker_image() -> None:
|
||||
last_update = process_pull_line(line, layers_info, status, last_update)
|
||||
|
||||
except DockerException as e:
|
||||
logger.debug("Failed to pull docker image %s", image, exc_info=True)
|
||||
logger.exception("Failed to pull docker image %s", image)
|
||||
console.print()
|
||||
error_text = Text()
|
||||
error_text.append("FAILED TO PULL IMAGE", style="bold red")
|
||||
@@ -977,37 +818,16 @@ def main() -> None:
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
||||
# scan argument parser (which requires a target) and before any scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
||||
from strix.interface.viewer.cli import run_view
|
||||
|
||||
run_view(sys.argv[2:])
|
||||
return
|
||||
|
||||
# `strix auth …` manages model-subscription sign-in and exits; it needs no
|
||||
# target, Docker, or scan setup.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "auth":
|
||||
from strix.interface.auth_cli import run_auth
|
||||
|
||||
sys.exit(run_auth(sys.argv[2:]))
|
||||
|
||||
args = parse_arguments()
|
||||
|
||||
if args.config:
|
||||
apply_config_override(validate_config_file(args.config))
|
||||
|
||||
start_background_check()
|
||||
if not args.non_interactive and prompt_update_if_available(Console()):
|
||||
if is_binary_install() and sys.platform != "win32":
|
||||
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
|
||||
sys.exit(0)
|
||||
|
||||
check_docker_installed()
|
||||
pull_docker_image()
|
||||
|
||||
validate_environment()
|
||||
asyncio.run(warm_up_llm(show_model_warning=args.non_interactive))
|
||||
asyncio.run(warm_up_llm())
|
||||
|
||||
persist_current()
|
||||
|
||||
@@ -1059,7 +879,6 @@ def main() -> None:
|
||||
|
||||
_telemetry_start_kwargs = {
|
||||
"model": load_settings().llm.model,
|
||||
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||
"scan_mode": args.scan_mode,
|
||||
"is_whitebox": is_whitebox_scan(args.targets_info),
|
||||
"interactive": not args.non_interactive,
|
||||
@@ -1093,7 +912,6 @@ def main() -> None:
|
||||
scarf.end(report_state, exit_reason=exit_reason)
|
||||
|
||||
results_path = run_dir_for(args.run_name)
|
||||
|
||||
display_completion_message(args, results_path)
|
||||
|
||||
if args.non_interactive:
|
||||
|
||||
+28
-236
@@ -6,7 +6,6 @@ import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as pkg_version
|
||||
@@ -32,9 +31,7 @@ from textual.widgets import Button, Label, Static, TextArea, Tree
|
||||
from textual.widgets.tree import TreeNode
|
||||
|
||||
from strix.config import load_settings
|
||||
from strix.config.models import is_recommended_or_frontier_model
|
||||
from strix.core.hooks import BudgetExceededError
|
||||
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||
from strix.core.runner import run_strix_scan
|
||||
from strix.interface.tui.live_view import TuiLiveView
|
||||
from strix.interface.tui.messages import send_user_message_to_agent
|
||||
@@ -43,12 +40,6 @@ from strix.interface.tui.renderers.agent_message_renderer import AgentMessageRen
|
||||
from strix.interface.tui.renderers.user_message_renderer import UserMessageRenderer
|
||||
from strix.interface.utils import build_tui_stats_text
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
from strix.report.writer import (
|
||||
guess_language_name,
|
||||
parse_fenced_code,
|
||||
resolve_lexer,
|
||||
safe_fence,
|
||||
)
|
||||
from strix.runtime import session_manager
|
||||
|
||||
|
||||
@@ -125,16 +116,9 @@ class SplashScreen(Static): # type: ignore[misc]
|
||||
self._animation_timer: Timer | None = None
|
||||
self._panel_static: Static | None = None
|
||||
self._version = "dev"
|
||||
self._non_frontier_model: str | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._version = get_package_version()
|
||||
try:
|
||||
model = (load_settings().llm.model or "").strip()
|
||||
except Exception:
|
||||
model = ""
|
||||
if model and not is_recommended_or_frontier_model(model):
|
||||
self._non_frontier_model = model
|
||||
self._animation_step = 0
|
||||
start_line = self._build_start_line_text(self._animation_step)
|
||||
panel = self._build_panel(start_line)
|
||||
@@ -161,7 +145,7 @@ class SplashScreen(Static): # type: ignore[misc]
|
||||
self._panel_static.update(panel)
|
||||
|
||||
def _build_panel(self, start_line: Text) -> Panel:
|
||||
rows = [
|
||||
content = Group(
|
||||
Align.center(Text(self.BANNER.strip("\n"), style=self.PRIMARY_GREEN, justify="center")),
|
||||
Align.center(Text(" ")),
|
||||
Align.center(self._build_welcome_text()),
|
||||
@@ -171,26 +155,9 @@ class SplashScreen(Static): # type: ignore[misc]
|
||||
Align.center(start_line.copy()),
|
||||
Align.center(Text(" ")),
|
||||
Align.center(self._build_url_text()),
|
||||
]
|
||||
if self._non_frontier_model:
|
||||
rows.extend(
|
||||
(
|
||||
Align.center(Text(" ")),
|
||||
Align.center(self._build_model_warning_text(self._non_frontier_model)),
|
||||
)
|
||||
)
|
||||
|
||||
return Panel.fit(Group(*rows), border_style=self.PRIMARY_GREEN, padding=(1, 6))
|
||||
|
||||
@staticmethod
|
||||
def _build_model_warning_text(model: str) -> Text:
|
||||
text = Text("⚠ ", style=Style(color="yellow", bold=True))
|
||||
text.append(model, style=Style(color="cyan", bold=True))
|
||||
text.append(
|
||||
" is not a recommended frontier model - pentest quality could be degraded",
|
||||
style=Style(color="yellow"),
|
||||
)
|
||||
return text
|
||||
|
||||
return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))
|
||||
|
||||
def _build_url_text(self) -> Text:
|
||||
return Text("strix.ai", style=Style(color=self.PRIMARY_GREEN, bold=True))
|
||||
@@ -337,11 +304,12 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
return "#65a30d"
|
||||
return "#6b7280"
|
||||
|
||||
def _highlight_python(self, code: str, language: str | None = None) -> Text:
|
||||
def _highlight_python(self, code: str) -> Text:
|
||||
try:
|
||||
from pygments.lexers import PythonLexer
|
||||
from pygments.styles import get_style_by_name
|
||||
|
||||
lexer = resolve_lexer(language, code)
|
||||
lexer = PythonLexer()
|
||||
style = get_style_by_name("native")
|
||||
colors = {
|
||||
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
|
||||
@@ -403,19 +371,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
text.append("Target: ", style=self.FIELD_STYLE)
|
||||
text.append(target)
|
||||
|
||||
dep_meta = vuln.get("dependency_metadata") or {}
|
||||
for label, key in (
|
||||
("Package", "package_name"),
|
||||
("Ecosystem", "package_ecosystem"),
|
||||
("Installed Version", "installed_version"),
|
||||
("Fixed Version", "fixed_version"),
|
||||
):
|
||||
value = dep_meta.get(key)
|
||||
if value:
|
||||
text.append("\n\n")
|
||||
text.append(f"{label}: ", style=self.FIELD_STYLE)
|
||||
text.append(str(value))
|
||||
|
||||
endpoint = vuln.get("endpoint", "")
|
||||
if endpoint:
|
||||
text.append("\n\n")
|
||||
@@ -434,18 +389,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
text.append("CVE: ", style=self.FIELD_STYLE)
|
||||
text.append(cve)
|
||||
|
||||
cwe = vuln.get("cwe", "")
|
||||
if cwe:
|
||||
text.append("\n\n")
|
||||
text.append("CWE: ", style=self.FIELD_STYLE)
|
||||
text.append(cwe)
|
||||
|
||||
fix_effort = vuln.get("fix_effort", "")
|
||||
if fix_effort:
|
||||
text.append("\n\n")
|
||||
text.append("Fix Effort: ", style=self.FIELD_STYLE)
|
||||
text.append(str(fix_effort).title())
|
||||
|
||||
cvss_breakdown = vuln.get("cvss_breakdown", {})
|
||||
if cvss_breakdown:
|
||||
cvss_parts = []
|
||||
@@ -491,13 +434,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
text.append("\n")
|
||||
text.append(technical_analysis)
|
||||
|
||||
evidence = vuln.get("evidence", "")
|
||||
if evidence:
|
||||
text.append("\n\n")
|
||||
text.append("Evidence", style=self.FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(evidence)
|
||||
|
||||
poc_description = vuln.get("poc_description", "")
|
||||
if poc_description:
|
||||
text.append("\n\n")
|
||||
@@ -507,11 +443,10 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
|
||||
poc_script_code = vuln.get("poc_script_code", "")
|
||||
if poc_script_code:
|
||||
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=self.FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(self._highlight_python(poc_code, poc_language))
|
||||
text.append_text(self._highlight_python(poc_script_code))
|
||||
|
||||
remediation_steps = vuln.get("remediation_steps", "")
|
||||
if remediation_steps:
|
||||
@@ -520,13 +455,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
text.append("\n")
|
||||
text.append(remediation_steps)
|
||||
|
||||
assumptions = vuln.get("assumptions", "")
|
||||
if assumptions:
|
||||
text.append("\n\n")
|
||||
text.append("Assumptions", style=self.FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(assumptions)
|
||||
|
||||
return text
|
||||
|
||||
def _get_markdown_report(self) -> str:
|
||||
@@ -548,27 +476,14 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
lines.append(f"**Agent:** {vuln['agent_name']}")
|
||||
if vuln.get("target"):
|
||||
lines.append(f"**Target:** {vuln['target']}")
|
||||
dep_meta = vuln.get("dependency_metadata") or {}
|
||||
if dep_meta.get("package_name"):
|
||||
lines.append(f"**Package:** {dep_meta['package_name']}")
|
||||
if dep_meta.get("package_ecosystem"):
|
||||
lines.append(f"**Ecosystem:** {dep_meta['package_ecosystem']}")
|
||||
if dep_meta.get("installed_version"):
|
||||
lines.append(f"**Installed Version:** {dep_meta['installed_version']}")
|
||||
if dep_meta.get("fixed_version"):
|
||||
lines.append(f"**Fixed Version:** {dep_meta['fixed_version']}")
|
||||
if vuln.get("endpoint"):
|
||||
lines.append(f"**Endpoint:** {vuln['endpoint']}")
|
||||
if vuln.get("method"):
|
||||
lines.append(f"**Method:** {vuln['method']}")
|
||||
if vuln.get("cve"):
|
||||
lines.append(f"**CVE:** {vuln['cve']}")
|
||||
if vuln.get("cwe"):
|
||||
lines.append(f"**CWE:** {vuln['cwe']}")
|
||||
if vuln.get("cvss") is not None:
|
||||
lines.append(f"**CVSS:** {vuln['cvss']}")
|
||||
if vuln.get("fix_effort"):
|
||||
lines.append(f"**Fix Effort:** {str(vuln['fix_effort']).title()}")
|
||||
|
||||
cvss_breakdown = vuln.get("cvss_breakdown", {})
|
||||
if cvss_breakdown:
|
||||
@@ -599,21 +514,15 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if vuln.get("technical_analysis"):
|
||||
lines.extend(["", "## Technical Analysis", "", vuln["technical_analysis"]])
|
||||
|
||||
if vuln.get("evidence"):
|
||||
lines.extend(["", "## Evidence", "", vuln["evidence"]])
|
||||
|
||||
if vuln.get("poc_description") or vuln.get("poc_script_code"):
|
||||
lines.extend(["", "## Proof of Concept", ""])
|
||||
if vuln.get("poc_description"):
|
||||
lines.append(vuln["poc_description"])
|
||||
lines.append("")
|
||||
if vuln.get("poc_script_code"):
|
||||
poc_language, poc_code = parse_fenced_code(vuln["poc_script_code"])
|
||||
fence_lang = poc_language or guess_language_name(poc_code)
|
||||
fence = safe_fence(poc_code)
|
||||
lines.append(f"{fence}{fence_lang}")
|
||||
lines.append(poc_code)
|
||||
lines.append(fence)
|
||||
lines.append("```python")
|
||||
lines.append(vuln["poc_script_code"])
|
||||
lines.append("```")
|
||||
|
||||
if vuln.get("code_locations"):
|
||||
lines.extend(["", "## Code Analysis", ""])
|
||||
@@ -629,9 +538,7 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if loc.get("label"):
|
||||
lines.append(f" {loc['label']}")
|
||||
if loc.get("snippet"):
|
||||
snippet = str(loc["snippet"])
|
||||
snippet_fence = safe_fence(snippet)
|
||||
lines.append(f"{snippet_fence}\n{snippet}\n{snippet_fence}")
|
||||
lines.append(f"```\n{loc['snippet']}\n```")
|
||||
if loc.get("fix_before") or loc.get("fix_after"):
|
||||
lines.append("**Suggested Fix:**")
|
||||
lines.append("```diff")
|
||||
@@ -645,9 +552,6 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
||||
if vuln.get("remediation_steps"):
|
||||
lines.extend(["", "## Remediation", "", vuln["remediation_steps"]])
|
||||
|
||||
if vuln.get("assumptions"):
|
||||
lines.extend(["", "## Assumptions", "", vuln["assumptions"]])
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -781,7 +685,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
Binding("ctrl+q", "request_quit", "Quit", priority=True),
|
||||
Binding("ctrl+c", "request_quit", "Quit", priority=True),
|
||||
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
|
||||
Binding("ctrl+o", "open_viewer", "Open Viewer", priority=True),
|
||||
]
|
||||
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
@@ -808,14 +711,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
self._displayed_events: list[str] = []
|
||||
|
||||
self._scan_thread: threading.Thread | None = None
|
||||
self._viewer_httpd: Any = None
|
||||
self._viewer_url: str | None = None
|
||||
self._scan_loop: asyncio.AbstractEventLoop | None = None
|
||||
self._scan_stop_event = threading.Event()
|
||||
self._scan_completed = threading.Event()
|
||||
self._scan_error: BaseException | None = None
|
||||
self._error_noted_agents: set[str] = set()
|
||||
self._budget_pause_notified = False
|
||||
|
||||
self._spinner_frame_index: int = 0
|
||||
self._sweep_num_squares: int = 6
|
||||
@@ -921,12 +820,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
|
||||
|
||||
viewer_cta = Static(self._viewer_cta_markup(), id="viewer_cta")
|
||||
viewer_cta.ALLOW_SELECT = False
|
||||
|
||||
sidebar = Vertical(
|
||||
viewer_cta, agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar"
|
||||
)
|
||||
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
|
||||
|
||||
content_container.mount(chat_area_container)
|
||||
content_container.mount(sidebar)
|
||||
@@ -1029,50 +923,26 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
else:
|
||||
self._agent_graph_sync_future = None
|
||||
try:
|
||||
parent_of, statuses, names, errors = future.result()
|
||||
parent_of, statuses, names = future.result()
|
||||
except Exception:
|
||||
logger.exception("TUI agent graph sync failed")
|
||||
else:
|
||||
for agent_id, status in statuses.items():
|
||||
error = errors.get(agent_id)
|
||||
self.live_view.upsert_agent(
|
||||
agent_id,
|
||||
name=names.get(agent_id, agent_id),
|
||||
parent_id=parent_of.get(agent_id),
|
||||
status=status,
|
||||
error_message=error,
|
||||
)
|
||||
if status in {"failed", "crashed"} and error:
|
||||
if agent_id not in self._error_noted_agents:
|
||||
self._error_noted_agents.add(agent_id)
|
||||
self.live_view.record_agent_error(agent_id, error)
|
||||
else:
|
||||
self._error_noted_agents.discard(agent_id)
|
||||
self._notify_budget_pause(statuses)
|
||||
|
||||
if self._scan_loop is None or self._scan_loop.is_closed():
|
||||
return
|
||||
|
||||
async def collect() -> tuple[
|
||||
dict[str, str | None], dict[str, Any], dict[str, str], dict[str, str]
|
||||
]:
|
||||
async def collect() -> tuple[dict[str, str | None], dict[str, Any], dict[str, str]]:
|
||||
return await self.coordinator.graph_snapshot()
|
||||
|
||||
self._agent_graph_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop)
|
||||
|
||||
def _notify_budget_pause(self, statuses: dict[str, Any]) -> None:
|
||||
paused = any(status == "budget_paused" for status in statuses.values())
|
||||
if paused and not self._budget_pause_notified:
|
||||
self._budget_pause_notified = True
|
||||
self.notify(
|
||||
"Budget limit reached \u2014 agents paused. Send a message to continue "
|
||||
"(this extends the budget), or ctrl-q to quit.",
|
||||
severity="warning",
|
||||
timeout=15,
|
||||
)
|
||||
elif not paused:
|
||||
self._budget_pause_notified = False
|
||||
|
||||
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
|
||||
if agent_id not in self.agent_nodes:
|
||||
return False
|
||||
@@ -1085,10 +955,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
status_indicators = {
|
||||
"running": "⚪",
|
||||
"waiting": "⏸",
|
||||
"budget_paused": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1274,26 +1142,20 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
text.append(msg)
|
||||
return (text, Text(), False)
|
||||
|
||||
if status in {"failed", "crashed"}:
|
||||
if status == "failed":
|
||||
error_msg = agent_data.get("error_message", "")
|
||||
text = Text()
|
||||
text.append(error_msg or "Agent failed", style="red")
|
||||
text.append(" · ", style="dim")
|
||||
text.append("Send message to resume", style="dim")
|
||||
if error_msg:
|
||||
text.append(error_msg, style="red")
|
||||
else:
|
||||
text.append("Scan failed", style="red")
|
||||
self._stop_dot_animation()
|
||||
return (text, Text(), False)
|
||||
|
||||
if status in {"waiting", "budget_paused"}:
|
||||
if status == "waiting":
|
||||
text = Text()
|
||||
keymap = Text()
|
||||
if status == "budget_paused":
|
||||
text.append("Budget limit reached", style="yellow")
|
||||
text.append(" \u00b7 ", style="dim")
|
||||
text.append("Send a message to continue", style="dim")
|
||||
keymap = keymap_styled([("ctrl-q", "quit")])
|
||||
else:
|
||||
text.append("Send message to resume", style="dim")
|
||||
return (text, keymap, False)
|
||||
text.append("Send message to resume", style="dim")
|
||||
return (text, Text(), False)
|
||||
|
||||
if status == "running":
|
||||
if self._agent_has_real_activity(agent_id):
|
||||
@@ -1439,7 +1301,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
def _start_dot_animation(self) -> None:
|
||||
if self._dot_animation_timer is None:
|
||||
self._dot_animation_timer = self.set_interval(0.06, self._animate_dots)
|
||||
self._dot_animation_timer = self.set_interval(0.25, self._animate_dots)
|
||||
|
||||
def _stop_dot_animation(self) -> None:
|
||||
if self._dot_animation_timer is not None:
|
||||
@@ -1518,7 +1380,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
coordinator=self.coordinator,
|
||||
interactive=True,
|
||||
max_budget_usd=getattr(self.args, "max_budget_usd", None),
|
||||
max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS),
|
||||
event_sink=self._capture_sdk_event,
|
||||
),
|
||||
)
|
||||
@@ -1526,7 +1387,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||
logger.info("Scan interrupted by user")
|
||||
except BudgetExceededError:
|
||||
logger.info("Scan stopped: --max-budget limit reached")
|
||||
# Defensive: the runner stops the scan cleanly on budget and
|
||||
# returns, so this normally never propagates. Treat it as a
|
||||
# graceful stop, not a scan error, if it ever does.
|
||||
logger.info("Scan stopped: --max-budget-usd limit reached")
|
||||
except (ConnectionError, TimeoutError) as e:
|
||||
logging.exception("Network error during scan")
|
||||
self._scan_error = e
|
||||
@@ -1581,10 +1445,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
status_indicators = {
|
||||
"running": "⚪",
|
||||
"waiting": "⏸",
|
||||
"budget_paused": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1628,10 +1490,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
status_indicators = {
|
||||
"running": "⚪",
|
||||
"waiting": "⏸",
|
||||
"budget_paused": "⏸",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"crashed": "🔴",
|
||||
"stopped": "■",
|
||||
}
|
||||
|
||||
@@ -1753,10 +1613,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
message=message,
|
||||
)
|
||||
if not submitted:
|
||||
if self._scan_completed.is_set():
|
||||
self.notify("The scan has ended; message was not sent", severity="warning")
|
||||
else:
|
||||
self.notify("Scan loop is not ready; message was not sent", severity="warning")
|
||||
self.notify("Scan loop is not ready; message was not sent", severity="warning")
|
||||
return
|
||||
|
||||
self._displayed_events.clear()
|
||||
@@ -1865,7 +1722,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
async def action_custom_quit(self) -> None:
|
||||
self._fire_sandbox_cleanup()
|
||||
self._shutdown_viewer()
|
||||
|
||||
if self._scan_thread and self._scan_thread.is_alive():
|
||||
self._scan_stop_event.set()
|
||||
@@ -1874,70 +1730,6 @@ class StrixTUIApp(App): # type: ignore[misc]
|
||||
|
||||
self.exit()
|
||||
|
||||
def _viewer_cta_markup(self, url: str | None = None) -> str:
|
||||
if url:
|
||||
return f"[@click=app.open_viewer][#22c55e]● Viewer running[/][/]\n[dim]{url}[/]"
|
||||
return "[@click=app.open_viewer]▶ Watch live in browser[/]"
|
||||
|
||||
def _set_viewer_cta(self, markup: str) -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
self.query_one("#viewer_cta", Static).update(markup)
|
||||
|
||||
def action_open_viewer(self) -> None:
|
||||
if self._viewer_url:
|
||||
with contextlib.suppress(Exception):
|
||||
webbrowser.open(self._viewer_url)
|
||||
return
|
||||
try:
|
||||
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||
|
||||
if not bundle_is_built():
|
||||
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
|
||||
return
|
||||
run_dir = self.report_state.get_run_dir()
|
||||
|
||||
def _viewer_steer(agent_id: str, message: str) -> bool:
|
||||
# Reuse the exact TUI delivery path, but target the agent the
|
||||
# web graph selected (not the TUI's current selection).
|
||||
return send_user_message_to_agent(
|
||||
coordinator=self.coordinator,
|
||||
loop=self._scan_loop,
|
||||
live_view=self.live_view,
|
||||
target_agent_id=agent_id,
|
||||
message=message,
|
||||
)
|
||||
|
||||
httpd, url, token = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
|
||||
except Exception:
|
||||
logger.debug("failed to start local viewer", exc_info=True)
|
||||
self._set_viewer_cta("[red]Viewer failed to start[/]")
|
||||
return
|
||||
self._viewer_httpd = httpd
|
||||
# Store the tokened URL so reopening the CTA re-authorizes the browser
|
||||
# (this viewer carries a steer handler, so the session is required).
|
||||
self._viewer_url = authorized_url(url, token)
|
||||
self._set_viewer_cta(self._viewer_cta_markup(self._viewer_url))
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
from strix.telemetry import posthog
|
||||
|
||||
live = self.report_state.run_record.get("status") not in {
|
||||
"completed",
|
||||
"stopped",
|
||||
"failed",
|
||||
"interrupted",
|
||||
}
|
||||
posthog.viewer_opened(source="tui", live=live)
|
||||
|
||||
def _shutdown_viewer(self) -> None:
|
||||
httpd = self._viewer_httpd
|
||||
if httpd is None:
|
||||
return
|
||||
self._viewer_httpd = None
|
||||
with contextlib.suppress(Exception):
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
def _fire_sandbox_cleanup(self) -> None:
|
||||
self.coordinator.mark_shutting_down()
|
||||
loop = self._scan_loop
|
||||
|
||||
@@ -24,26 +24,14 @@ def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[
|
||||
if not agents_db.exists() or not session_ids:
|
||||
return []
|
||||
session_id_set = set(session_ids)
|
||||
# Open read-only: the scan process may be actively writing this WAL database
|
||||
# from another process (the local viewer tails it live), and a reader must
|
||||
# never lock or mutate it. mode=ro (not immutable=1) still reads the latest
|
||||
# committed WAL state; WAL permits concurrent readers alongside the writer.
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
conn = sqlite3.connect(
|
||||
f"file:{agents_db}?mode=ro",
|
||||
uri=True,
|
||||
check_same_thread=False,
|
||||
)
|
||||
rows = conn.execute(
|
||||
"select id, session_id, message_data, created_at from agent_messages order by id"
|
||||
).fetchall()
|
||||
with sqlite3.connect(agents_db) as conn:
|
||||
rows = conn.execute(
|
||||
"select id, session_id, message_data, created_at from agent_messages order by id"
|
||||
).fetchall()
|
||||
except sqlite3.Error:
|
||||
logger.exception("Failed to hydrate TUI history from %s", agents_db)
|
||||
return []
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
items: list[tuple[str, dict[str, Any], str]] = []
|
||||
for row_id, agent_id, message_data, created_at in rows:
|
||||
|
||||
@@ -20,7 +20,7 @@ class TuiLiveView:
|
||||
self.events: list[dict[str, Any]] = []
|
||||
self._next_event_id = 1
|
||||
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
|
||||
self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
||||
state_dir = runtime_state_dir(run_dir)
|
||||
@@ -86,17 +86,6 @@ class TuiLiveView:
|
||||
current["error_message"] = error_message
|
||||
current["updated_at"] = now
|
||||
|
||||
def record_agent_error(self, agent_id: str, error: str) -> None:
|
||||
self._append_event(
|
||||
agent_id,
|
||||
"chat",
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (f"An error occurred: {error}\nI'm now waiting for new instructions."),
|
||||
"metadata": {"source": "agent_error"},
|
||||
},
|
||||
)
|
||||
|
||||
def record_user_message(self, agent_id: str, content: str) -> None:
|
||||
self._append_event(
|
||||
agent_id,
|
||||
@@ -223,8 +212,7 @@ class TuiLiveView:
|
||||
timestamp: str | None = None,
|
||||
) -> None:
|
||||
call_id = call["call_id"]
|
||||
event_key = (agent_id, call_id)
|
||||
existing = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||
existing = self._tool_event_by_call_id.get(call_id)
|
||||
tool_data = {
|
||||
"tool_name": call["tool_name"],
|
||||
"args": call["args"],
|
||||
@@ -234,7 +222,7 @@ class TuiLiveView:
|
||||
}
|
||||
if existing is None:
|
||||
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
||||
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||
self._tool_event_by_call_id[call_id] = event
|
||||
else:
|
||||
existing["data"].update(tool_data)
|
||||
self._bump_event(existing, timestamp=timestamp)
|
||||
@@ -250,8 +238,7 @@ class TuiLiveView:
|
||||
timestamp: str | None = None,
|
||||
) -> None:
|
||||
call_id = output["call_id"]
|
||||
event_key = (agent_id, call_id)
|
||||
event = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||
event = self._tool_event_by_call_id.get(call_id)
|
||||
if event is None:
|
||||
event = self._append_event(
|
||||
agent_id,
|
||||
@@ -265,7 +252,7 @@ class TuiLiveView:
|
||||
},
|
||||
timestamp=timestamp,
|
||||
)
|
||||
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||
self._tool_event_by_call_id[call_id] = event
|
||||
|
||||
result = _parse_json_value(output["output"])
|
||||
event["data"]["result"] = result
|
||||
|
||||
@@ -7,13 +7,6 @@ from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
|
||||
def _author_label(note: dict[str, Any]) -> str:
|
||||
if note.get("by_you"):
|
||||
return "you"
|
||||
agent_name = note.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateNoteRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_note"
|
||||
@@ -130,9 +123,6 @@ class ListNotesRenderer(BaseToolRenderer):
|
||||
text.append("\n - ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
|
||||
if note_content:
|
||||
text.append("\n ")
|
||||
@@ -166,9 +156,6 @@ class GetNoteRenderer(BaseToolRenderer):
|
||||
text.append("\n ")
|
||||
text.append(title)
|
||||
text.append(f" ({category})", style="dim")
|
||||
author = _author_label(note)
|
||||
if author:
|
||||
text.append(f" by {author}", style="dim")
|
||||
if content:
|
||||
text.append("\n ")
|
||||
text.append(content, style="dim")
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from functools import cache
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pygments.lexers import PythonLexer
|
||||
from pygments.styles import get_style_by_name
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from strix.report.writer import parse_fenced_code, resolve_lexer
|
||||
|
||||
from .base_renderer import BaseToolRenderer
|
||||
from .registry import register_tool_renderer
|
||||
|
||||
@@ -62,8 +61,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _highlight_code(cls, code: str, language: str | None) -> Text:
|
||||
lexer = resolve_lexer(language, code)
|
||||
def _highlight_python(cls, code: str) -> Text:
|
||||
lexer = PythonLexer()
|
||||
text = Text()
|
||||
|
||||
for token_type, token_value in lexer.get_tokens(code):
|
||||
@@ -235,11 +234,10 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
text.append(poc_description)
|
||||
|
||||
if poc_script_code:
|
||||
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||
text.append("\n\n")
|
||||
text.append("PoC Code", style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append_text(cls._highlight_code(poc_code, poc_language))
|
||||
text.append_text(cls._highlight_python(poc_script_code))
|
||||
|
||||
if remediation_steps:
|
||||
text.append("\n\n")
|
||||
@@ -258,290 +256,3 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(padded, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class CreateDependencyReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "create_dependency_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
SEVERITY_COLORS: ClassVar[dict[str, str]] = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_cvss_color(cls, cvss_score: float) -> str:
|
||||
if cvss_score >= 9.0:
|
||||
return "#dc2626"
|
||||
if cvss_score >= 7.0:
|
||||
return "#ea580c"
|
||||
if cvss_score >= 4.0:
|
||||
return "#d97706"
|
||||
if cvss_score >= 0.1:
|
||||
return "#65a30d"
|
||||
return "#6b7280"
|
||||
|
||||
@classmethod
|
||||
def _render_unsuccessful(cls, args: dict[str, Any], result: dict[str, Any]) -> Static:
|
||||
text = Text()
|
||||
text.append("📦 ")
|
||||
text.append("Dependency (SCA) Report", style="bold #ea580c")
|
||||
title = args.get("title", "")
|
||||
if title:
|
||||
text.append("\n\n")
|
||||
text.append("Title: ", style=FIELD_STYLE)
|
||||
text.append(title)
|
||||
|
||||
warning = result.get("warning")
|
||||
if result.get("success") is False:
|
||||
errors = result.get("errors")
|
||||
detail = (
|
||||
"; ".join(errors) if isinstance(errors, list) and errors else result.get("error")
|
||||
)
|
||||
label, style = "✗ Not created: ", "bold #dc2626"
|
||||
fallback = "Report was not created."
|
||||
else:
|
||||
detail = warning
|
||||
label, style = "⚠ Not persisted: ", "bold #d97706"
|
||||
fallback = "Report could not be persisted."
|
||||
text.append("\n\n")
|
||||
text.append(label, style=style)
|
||||
text.append(str(detail or fallback))
|
||||
|
||||
padded = Text()
|
||||
padded.append("\n\n")
|
||||
padded.append_text(text)
|
||||
padded.append("\n\n")
|
||||
return Static(padded, classes=cls.get_css_classes("failed"))
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static: # noqa: PLR0912, PLR0915
|
||||
args = tool_data.get("args", {})
|
||||
result = tool_data.get("result", {})
|
||||
|
||||
if isinstance(result, dict) and (result.get("success") is False or result.get("warning")):
|
||||
return cls._render_unsuccessful(args, result)
|
||||
|
||||
title = args.get("title", "")
|
||||
description = args.get("description", "")
|
||||
impact = args.get("impact", "")
|
||||
target = args.get("target", "")
|
||||
technical_analysis = args.get("technical_analysis", "")
|
||||
remediation_steps = args.get("remediation_steps", "")
|
||||
assumptions = args.get("assumptions", "")
|
||||
|
||||
package_name = args.get("package_name", "")
|
||||
package_ecosystem = args.get("package_ecosystem", "")
|
||||
installed_version = args.get("installed_version", "")
|
||||
fixed_version = args.get("fixed_version", "")
|
||||
cve = args.get("cve", "")
|
||||
cwe = args.get("cwe", "")
|
||||
advisory_cvss = args.get("advisory_cvss")
|
||||
fix_effort = args.get("fix_effort", "")
|
||||
|
||||
severity = ""
|
||||
if isinstance(result, dict):
|
||||
severity = result.get("severity", "")
|
||||
|
||||
text = Text()
|
||||
text.append("📦 ")
|
||||
text.append("Dependency (SCA) Report", style="bold #ea580c")
|
||||
|
||||
if title:
|
||||
text.append("\n\n")
|
||||
text.append("Title: ", style=FIELD_STYLE)
|
||||
text.append(title)
|
||||
|
||||
if severity:
|
||||
text.append("\n\n")
|
||||
text.append("Severity: ", style=FIELD_STYLE)
|
||||
severity_color = cls.SEVERITY_COLORS.get(severity.lower(), "#6b7280")
|
||||
text.append(severity.upper(), style=f"bold {severity_color}")
|
||||
|
||||
if advisory_cvss is not None:
|
||||
text.append("\n\n")
|
||||
text.append("Advisory CVSS: ", style=FIELD_STYLE)
|
||||
try:
|
||||
score = float(advisory_cvss)
|
||||
text.append(str(score), style=f"bold {cls._get_cvss_color(score)}")
|
||||
except (TypeError, ValueError):
|
||||
text.append(str(advisory_cvss), style=DIM_STYLE)
|
||||
|
||||
if cve:
|
||||
text.append("\n\n")
|
||||
text.append("CVE: ", style=FIELD_STYLE)
|
||||
text.append(cve)
|
||||
|
||||
if cwe:
|
||||
text.append("\n\n")
|
||||
text.append("CWE: ", style=FIELD_STYLE)
|
||||
text.append(cwe)
|
||||
|
||||
if package_name:
|
||||
text.append("\n\n")
|
||||
text.append("Package: ", style=FIELD_STYLE)
|
||||
text.append(package_name, style=FILE_STYLE)
|
||||
if package_ecosystem:
|
||||
text.append(f" ({package_ecosystem})", style=DIM_STYLE)
|
||||
|
||||
if installed_version:
|
||||
text.append("\n\n")
|
||||
text.append("Installed: ", style=FIELD_STYLE)
|
||||
text.append(installed_version, style=BEFORE_STYLE)
|
||||
if fixed_version:
|
||||
text.append(" → ", style=DIM_STYLE)
|
||||
text.append("Fixed: ", style=FIELD_STYLE)
|
||||
text.append(fixed_version, style=AFTER_STYLE)
|
||||
|
||||
if fix_effort:
|
||||
text.append("\n\n")
|
||||
text.append("Fix Effort: ", style=FIELD_STYLE)
|
||||
text.append(fix_effort)
|
||||
|
||||
if target:
|
||||
text.append("\n\n")
|
||||
text.append("Target: ", style=FIELD_STYLE)
|
||||
text.append(target)
|
||||
|
||||
for label, value in [
|
||||
("Description", description),
|
||||
("Impact", impact),
|
||||
("Technical Analysis", technical_analysis),
|
||||
("Assumptions", assumptions),
|
||||
("Remediation", remediation_steps),
|
||||
]:
|
||||
if value:
|
||||
text.append("\n\n")
|
||||
text.append(label, style=FIELD_STYLE)
|
||||
text.append("\n")
|
||||
text.append(value)
|
||||
|
||||
if not title:
|
||||
text.append("\n ")
|
||||
text.append("Creating dependency report...", style="dim")
|
||||
|
||||
padded = Text()
|
||||
padded.append("\n\n")
|
||||
padded.append_text(text)
|
||||
padded.append("\n\n")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(padded, classes=css_classes)
|
||||
|
||||
|
||||
_LIST_SEVERITY_COLORS = {
|
||||
"critical": "#dc2626",
|
||||
"high": "#ea580c",
|
||||
"medium": "#d97706",
|
||||
"low": "#65a30d",
|
||||
"info": "#0284c7",
|
||||
"none": "#6b7280",
|
||||
}
|
||||
|
||||
|
||||
def _severity_style(severity: Any) -> str:
|
||||
return _LIST_SEVERITY_COLORS.get(str(severity or "").lower(), "#d97706")
|
||||
|
||||
|
||||
def _author_label(report: dict[str, Any]) -> str:
|
||||
if report.get("by_you"):
|
||||
return "you"
|
||||
agent_name = report.get("agent_name")
|
||||
return str(agent_name).strip() if agent_name else ""
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class ListReportsRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "list_reports"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("reports", style="dim")
|
||||
|
||||
if isinstance(tool_data.get("result"), str) and str(tool_data["result"]).strip():
|
||||
text.append("\n ")
|
||||
text.append(str(tool_data["result"]).strip(), style="dim")
|
||||
elif result.get("success"):
|
||||
total = result.get("total_count", 0)
|
||||
reports = _coerce_list_of_dicts(result.get("reports"))
|
||||
counts = _coerce_dict(result.get("severity_counts"))
|
||||
|
||||
text.append(f" ({total})", style="dim")
|
||||
for sev, count in counts.items():
|
||||
text.append(" ")
|
||||
text.append(f"{sev} {count}", style=_severity_style(sev))
|
||||
|
||||
if not reports:
|
||||
text.append("\n ")
|
||||
text.append("No reports filed yet", style="dim")
|
||||
else:
|
||||
for report in reports:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n - ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
text.append("Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
|
||||
@register_tool_renderer
|
||||
class GetReportRenderer(BaseToolRenderer):
|
||||
tool_name: ClassVar[str] = "get_report"
|
||||
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||
|
||||
@classmethod
|
||||
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||
result = _coerce_dict(tool_data.get("result"))
|
||||
|
||||
text = Text()
|
||||
text.append("◆ ", style="#ef4444")
|
||||
text.append("report read", style="dim")
|
||||
|
||||
report = _coerce_dict(result.get("report")) if result.get("success") else {}
|
||||
if report:
|
||||
rid = str(report.get("id", "")).strip()
|
||||
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||
severity = str(report.get("severity", "")).strip()
|
||||
text.append("\n ")
|
||||
if severity:
|
||||
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||
text.append(" ")
|
||||
if rid:
|
||||
text.append(f"{rid} ", style="dim")
|
||||
text.append(title)
|
||||
author = _author_label(report)
|
||||
if author:
|
||||
text.append(f" ({author})", style="dim")
|
||||
target = str(report.get("target", "")).strip()
|
||||
if target:
|
||||
text.append("\n ")
|
||||
text.append(target, style="dim")
|
||||
else:
|
||||
text.append("\n ")
|
||||
detail = result.get("error") if result.get("success") is False else None
|
||||
text.append(str(detail) if detail else "Loading...", style="dim")
|
||||
|
||||
css_classes = cls.get_css_classes("completed")
|
||||
return Static(text, classes=css_classes)
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
"""Update notifications and self-update for the strix CLI.
|
||||
|
||||
Follows the pattern used by tools like gh, uv, and pip: a background,
|
||||
rate-limited (once per 24h) check against the release source, a cached
|
||||
result in ``~/.strix``, a non-intrusive notice with the upgrade command
|
||||
for the detected install method, and a ``strix --update`` self-update
|
||||
path for the standalone binary install.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from strix.telemetry._common import get_version
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_REPO = "usestrix/strix"
|
||||
PYPI_PACKAGE = "strix-agent"
|
||||
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
REQUEST_TIMEOUT_SECONDS = 5
|
||||
|
||||
_CACHE_PATH = Path.home() / ".strix" / "update-check.json"
|
||||
|
||||
_background_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _is_disabled() -> bool:
|
||||
return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any(
|
||||
os.environ.get(key)
|
||||
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI")
|
||||
)
|
||||
|
||||
|
||||
def is_binary_install() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def get_install_method() -> str:
|
||||
if is_binary_install():
|
||||
return "binary"
|
||||
prefix = str(Path(sys.prefix)).replace("\\", "/")
|
||||
if "/pipx/" in prefix or prefix.endswith("/pipx"):
|
||||
return "pipx"
|
||||
if "/uv/tools/" in prefix:
|
||||
return "uv"
|
||||
return "pip"
|
||||
|
||||
|
||||
def get_upgrade_command(method: str | None = None) -> str:
|
||||
method = method or get_install_method()
|
||||
commands = {
|
||||
"binary": "strix --update",
|
||||
"pipx": "pipx upgrade strix-agent",
|
||||
"uv": "uv tool upgrade strix-agent",
|
||||
"pip": "pip install --upgrade strix-agent",
|
||||
}
|
||||
return commands[method]
|
||||
|
||||
|
||||
def _parse_version(value: str) -> tuple[int, ...] | None:
|
||||
parts = value.strip().lstrip("v").split(".")
|
||||
try:
|
||||
return tuple(int(part) for part in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
latest_parts = _parse_version(latest)
|
||||
current_parts = _parse_version(current)
|
||||
if latest_parts is None or current_parts is None:
|
||||
return False
|
||||
return latest_parts > current_parts
|
||||
|
||||
|
||||
def _fetch_latest_version() -> str | None:
|
||||
try:
|
||||
if is_binary_install():
|
||||
response = requests.get(
|
||||
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
tag = response.json().get("tag_name", "")
|
||||
return tag.lstrip("v") or None
|
||||
response = requests.get(
|
||||
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
version = response.json().get("info", {}).get("version")
|
||||
return str(version) if version else None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("update check failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_asset_digest(version: str, filename: str) -> str | None:
|
||||
"""Return the expected sha256 (hex) for a release asset, if the API provides one."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
for asset in response.json().get("assets", []):
|
||||
if asset.get("name") == filename:
|
||||
digest = asset.get("digest") or ""
|
||||
if digest.startswith("sha256:"):
|
||||
return digest.removeprefix("sha256:")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("release asset digest lookup failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_cache() -> dict[str, object]:
|
||||
try:
|
||||
with _CACHE_PATH.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return cast("dict[str, object]", data)
|
||||
except Exception: # noqa: BLE001, S110
|
||||
pass # nosec B110
|
||||
return {}
|
||||
|
||||
|
||||
def _write_cache(**fields: object) -> None:
|
||||
try:
|
||||
cache = _read_cache()
|
||||
cache.update(fields)
|
||||
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
|
||||
except Exception: # noqa: BLE001, S110
|
||||
pass # nosec B110
|
||||
|
||||
|
||||
def skip_version(version: str) -> None:
|
||||
"""Remember not to prompt again for this version (newer releases still notify)."""
|
||||
_write_cache(skipped_version=version)
|
||||
|
||||
|
||||
def _refresh_cache() -> None:
|
||||
latest = _fetch_latest_version()
|
||||
if latest:
|
||||
_write_cache(latest_version=latest, checked_at=time.time())
|
||||
|
||||
|
||||
def start_background_check() -> None:
|
||||
"""Refresh the cached latest-version info in a daemon thread (at most once per 24h)."""
|
||||
global _background_thread # noqa: PLW0603
|
||||
if _is_disabled():
|
||||
return
|
||||
cache = _read_cache()
|
||||
checked_at = cache.get("checked_at")
|
||||
if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS:
|
||||
return
|
||||
_background_thread = threading.Thread(target=_refresh_cache, daemon=True)
|
||||
_background_thread.start()
|
||||
|
||||
|
||||
def get_available_update(*, respect_skip: bool = True) -> str | None:
|
||||
"""Return the newer version from the cache, or None if up to date / unknown."""
|
||||
if _is_disabled():
|
||||
return None
|
||||
if _background_thread is not None:
|
||||
_background_thread.join(timeout=0.2)
|
||||
cache = _read_cache()
|
||||
latest = cache.get("latest_version")
|
||||
current = get_version()
|
||||
if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current):
|
||||
return None
|
||||
if respect_skip and cache.get("skipped_version") == latest:
|
||||
return None
|
||||
return latest
|
||||
|
||||
|
||||
def notify_update(console: Console) -> None:
|
||||
latest = get_available_update()
|
||||
if not latest:
|
||||
return
|
||||
console.print(
|
||||
f"[#eab308]A new version of strix is available:[/] "
|
||||
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||
f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def run_package_upgrade(console: Console, method: str) -> bool:
|
||||
"""Upgrade a package-manager install by running its upgrade command."""
|
||||
command = get_upgrade_command(method).split()
|
||||
console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]")
|
||||
try:
|
||||
result = subprocess.run(command, check=False) # noqa: S603
|
||||
except OSError as e:
|
||||
console.print(f"[bold red]Update failed:[/] {e}")
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
console.print(
|
||||
f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] "
|
||||
f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||
)
|
||||
return False
|
||||
console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]")
|
||||
return True
|
||||
|
||||
|
||||
def prompt_update_if_available(console: Console) -> bool:
|
||||
"""Offer an interactive update before a scan starts.
|
||||
|
||||
Returns True if strix was updated (caller should re-exec / exit).
|
||||
"""
|
||||
latest = get_available_update()
|
||||
if not latest or not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return False
|
||||
console.print()
|
||||
console.print(
|
||||
f"[#eab308]A new version of strix is available:[/] "
|
||||
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||
)
|
||||
console.print(
|
||||
"[dim] y — update now n — not now (ask again next run) s — skip this version[/]"
|
||||
)
|
||||
choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n")
|
||||
console.print()
|
||||
if choice == "s":
|
||||
skip_version(latest)
|
||||
return False
|
||||
if choice != "y":
|
||||
return False
|
||||
method = get_install_method()
|
||||
if method == "binary":
|
||||
return self_update(console, version=latest)
|
||||
return run_package_upgrade(console, method)
|
||||
|
||||
|
||||
def _release_target() -> str | None:
|
||||
raw_os = platform.system().lower()
|
||||
os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os)
|
||||
arch = platform.machine().lower()
|
||||
arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch)
|
||||
if os_name is None:
|
||||
return None
|
||||
target = f"{os_name}-{arch}"
|
||||
supported = {
|
||||
"linux-x86_64",
|
||||
"linux-arm64",
|
||||
"macos-x86_64",
|
||||
"macos-arm64",
|
||||
"windows-x86_64",
|
||||
}
|
||||
return target if target in supported else None
|
||||
|
||||
|
||||
def _download_and_replace(version: str, target: str, console: Console) -> bool:
|
||||
is_windows = target.startswith("windows")
|
||||
archive_ext = ".zip" if is_windows else ".tar.gz"
|
||||
filename = f"strix-{version}-{target}{archive_ext}"
|
||||
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}"
|
||||
binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "")
|
||||
current_exe = Path(sys.executable).resolve()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
archive_path = tmp_dir / filename
|
||||
console.print(f"[dim]Downloading[/] {url}")
|
||||
with requests.get( # nosec B113
|
||||
url,
|
||||
stream=True,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS * 12,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
with archive_path.open("wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=1 << 20):
|
||||
f.write(chunk)
|
||||
|
||||
expected_digest = _fetch_asset_digest(version, filename)
|
||||
if expected_digest:
|
||||
actual_digest = _sha256_file(archive_path)
|
||||
if actual_digest != expected_digest:
|
||||
raise RuntimeError(
|
||||
f"checksum mismatch for {filename}: "
|
||||
f"expected sha256 {expected_digest}, got {actual_digest}"
|
||||
)
|
||||
else:
|
||||
console.print("[dim yellow]No published checksum available; skipping verification[/]")
|
||||
|
||||
if is_windows:
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
zf.extract(binary_name, tmp_dir)
|
||||
else:
|
||||
with tarfile.open(archive_path, "r:gz") as tf:
|
||||
tf.extract(binary_name, tmp_dir, filter="data")
|
||||
|
||||
new_binary = tmp_dir / binary_name
|
||||
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
staged = current_exe.with_name(current_exe.name + ".new")
|
||||
try:
|
||||
shutil.copy2(new_binary, staged)
|
||||
if is_windows:
|
||||
# Windows can't replace a running executable in place; move it aside first.
|
||||
old = current_exe.with_name(current_exe.name + ".old")
|
||||
old.unlink(missing_ok=True)
|
||||
current_exe.rename(old)
|
||||
try:
|
||||
staged.replace(current_exe)
|
||||
except Exception:
|
||||
old.rename(current_exe)
|
||||
raise
|
||||
else:
|
||||
staged.replace(current_exe)
|
||||
except Exception:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
def self_update(console: Console | None = None, version: str | None = None) -> bool:
|
||||
"""Replace the running standalone binary with the latest release.
|
||||
|
||||
Returns True on success. For package-manager installs this only
|
||||
prints the right upgrade command and returns False.
|
||||
"""
|
||||
console = console or Console()
|
||||
|
||||
if not is_binary_install():
|
||||
method = get_install_method()
|
||||
console.print(
|
||||
f"[#eab308]This strix was installed via {method};[/] "
|
||||
f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
latest = version or _fetch_latest_version()
|
||||
if not latest:
|
||||
console.print("[bold red]Could not determine the latest strix version.[/]")
|
||||
return False
|
||||
|
||||
current = get_version()
|
||||
if current != "unknown" and not _is_newer(latest, current):
|
||||
console.print(f"[#22c55e]strix {current} is already the latest version.[/]")
|
||||
return True
|
||||
|
||||
target = _release_target()
|
||||
if not target:
|
||||
console.print(
|
||||
f"[bold red]No prebuilt binary for this platform "
|
||||
f"({platform.system()}/{platform.machine()}).[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
_download_and_replace(latest, target, console)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("self-update failed", exc_info=True)
|
||||
console.print(f"[bold red]Update failed:[/] {e}")
|
||||
console.print(
|
||||
"[dim]You can reinstall manually with:[/] "
|
||||
"[#60a5fa]curl -sSL https://strix.ai/install | bash[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
_write_cache(latest_version=latest, checked_at=time.time())
|
||||
console.print(f"[#22c55e]✓ Updated strix to {latest}[/]")
|
||||
return True
|
||||
+14
-44
@@ -11,10 +11,11 @@ import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import docker
|
||||
import requests
|
||||
from docker.errors import DockerException, ImageNotFound
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -252,20 +253,6 @@ def _llm_usage(report_state: Any) -> dict[str, Any]:
|
||||
return usage if isinstance(usage, dict) else {}
|
||||
|
||||
|
||||
def _is_subscription(report_state: Any) -> bool:
|
||||
"""Whether this run uses a model subscription (no metered cost).
|
||||
|
||||
Prefers the run record so it's correct for hydrated/resumed runs; falls back
|
||||
to current settings.
|
||||
"""
|
||||
record = getattr(report_state, "run_record", None)
|
||||
if isinstance(record, dict) and record.get("auth_mode"):
|
||||
return record.get("auth_mode") == "subscription"
|
||||
from strix.config import codex
|
||||
|
||||
return codex.auth_mode(load_settings().llm.model) == "subscription"
|
||||
|
||||
|
||||
def _int_stat(usage: dict[str, Any], key: str) -> int:
|
||||
try:
|
||||
return max(0, int(usage.get(key) or 0))
|
||||
@@ -296,16 +283,11 @@ def _build_llm_usage_stats(
|
||||
*,
|
||||
live: bool = False,
|
||||
) -> None:
|
||||
subscription = _is_subscription(report_state)
|
||||
usage = _llm_usage(report_state)
|
||||
if not usage or _int_stat(usage, "requests") <= 0:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
if subscription:
|
||||
stats_text.append("$0.00 ", style="#22c55e")
|
||||
stats_text.append("(subscription) ", style="dim")
|
||||
else:
|
||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||
stats_text.append("· ", style="dim white")
|
||||
stats_text.append("Tokens ", style="dim")
|
||||
stats_text.append("0", style="white")
|
||||
@@ -330,12 +312,7 @@ def _build_llm_usage_stats(
|
||||
stats_text.append("Output Tokens ", style="dim")
|
||||
stats_text.append(format_token_count(output_tokens), style="white")
|
||||
|
||||
if subscription:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append("$0.00", style="#22c55e")
|
||||
stats_text.append(" (subscription)", style="dim")
|
||||
elif live or cost > 0:
|
||||
if live or cost > 0:
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("Cost ", style="dim")
|
||||
stats_text.append(f"${cost:.4f}", style="#fbbf24")
|
||||
@@ -360,9 +337,6 @@ def build_live_stats_text(report_state: Any) -> Text:
|
||||
model = load_settings().llm.model or "unknown"
|
||||
stats_text.append("Model ", style="dim")
|
||||
stats_text.append(str(model), style="white")
|
||||
if _is_subscription(report_state):
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
stats_text.append("\n")
|
||||
|
||||
vuln_count = len(report_state.vulnerability_reports)
|
||||
@@ -405,10 +379,6 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
|
||||
model = load_settings().llm.model or "unknown"
|
||||
stats_text.append(str(model), style="white")
|
||||
subscription = _is_subscription(report_state)
|
||||
if subscription:
|
||||
stats_text.append("\n")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
|
||||
usage = _llm_usage(report_state)
|
||||
if usage and _int_stat(usage, "total_tokens") > 0:
|
||||
@@ -418,10 +388,7 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
style="white",
|
||||
)
|
||||
cost = _float_stat(usage, "cost")
|
||||
if subscription:
|
||||
stats_text.append(" · ", style="white")
|
||||
stats_text.append("$0.00", style="white")
|
||||
elif cost > 0:
|
||||
if cost > 0:
|
||||
stats_text.append(" · ", style="white")
|
||||
stats_text.append(f"${cost:.2f}", style="white")
|
||||
|
||||
@@ -1087,12 +1054,13 @@ def resolve_diff_scope_context(
|
||||
def _is_http_git_repo(url: str) -> bool:
|
||||
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
|
||||
try:
|
||||
resp = requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10)
|
||||
except (requests.RequestException, ValueError):
|
||||
req = Request(check_url, headers={"User-Agent": "git/strix"}) # noqa: S310
|
||||
with urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
|
||||
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
||||
except HTTPError as e:
|
||||
return e.code == 401
|
||||
except (URLError, OSError, ValueError):
|
||||
return False
|
||||
if resp.status_code >= 400:
|
||||
return resp.status_code == 401
|
||||
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
||||
|
||||
|
||||
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
|
||||
@@ -1179,7 +1147,9 @@ def read_target_list_file(path_str: str) -> list[str]:
|
||||
if (target := line.strip()) and not target.startswith("#")
|
||||
]
|
||||
except UnicodeDecodeError as e:
|
||||
raise ValueError(f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}") from e
|
||||
raise ValueError(
|
||||
f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}"
|
||||
) from e
|
||||
except OSError as e:
|
||||
raise ValueError(f"Failed to read target list file '{path_str}': {e!s}") from e
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Local web viewer for Strix runs.
|
||||
|
||||
Serves a prebuilt single-page app that renders a run (live or finished) read
|
||||
directly from the run's on-disk files. No cloud dependency, no file picker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from strix.interface.viewer.server import serve
|
||||
|
||||
|
||||
__all__ = ["serve"]
|
||||
@@ -1,268 +0,0 @@
|
||||
"""Viewer email verification state and the relay client.
|
||||
|
||||
The local viewer proxies email verification and encrypted-report delivery to
|
||||
the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay
|
||||
directly, and the report password generated locally is never sent to it.
|
||||
|
||||
State lives in ``~/.strix/viewer-auth.json`` (0600). ``is_verified`` is a local
|
||||
flag that unlocks browsing the run history list; the relay still enforces token
|
||||
expiry when a report is actually sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config.loader import load_settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTH_PATH = Path.home() / ".strix" / "viewer-auth.json"
|
||||
|
||||
_OTP_TIMEOUT = 15
|
||||
_SEND_TIMEOUT = 30
|
||||
|
||||
|
||||
class RelayError(Exception):
|
||||
"""A relay call failed. ``code`` is a stable, machine-readable reason."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
# --- local state ------------------------------------------------------------
|
||||
|
||||
|
||||
def read_auth() -> dict[str, Any] | None:
|
||||
"""Return the stored ``{email, token, verified_at}`` record, or None."""
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
email = data.get("email")
|
||||
token = data.get("token")
|
||||
if not isinstance(email, str) or not email or not isinstance(token, str) or not token:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def parse_expiry(raw: object) -> datetime | None:
|
||||
"""Parse a relay ``expires_at`` value into an aware UTC datetime.
|
||||
|
||||
Accepts both ISO 8601 strings and epoch seconds (as a number or numeric
|
||||
string) so a valid relay expiry is not misread as missing. Returns None only
|
||||
when it is genuinely absent or unparseable; both the local gate (see
|
||||
``is_verified``) and OTP verification (see ``otp_verify``) fail closed on such
|
||||
values, matching the relay, which rejects a token with no valid expiry.
|
||||
"""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
if isinstance(raw, int | float):
|
||||
return _from_epoch(raw)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
return _from_epoch(float(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _expiry(record: dict[str, Any]) -> datetime | None:
|
||||
"""The stored ``verified_at`` parsed to a datetime, or None if unusable."""
|
||||
return parse_expiry(record.get("verified_at"))
|
||||
|
||||
|
||||
def _from_epoch(seconds: float) -> datetime | None:
|
||||
"""Epoch seconds → aware UTC datetime, or None if out of range."""
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=UTC)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_verified() -> bool:
|
||||
"""True when a usable email + token record with a valid future expiry exists.
|
||||
|
||||
The expiry returned by OTP verification is enforced here so history stops
|
||||
unlocking once the token lapses. It fails closed: a record whose expiry is
|
||||
absent, blank, or unparseable requires re-verification rather than unlocking
|
||||
forever, keeping the local gate in step with the relay (which rejects an
|
||||
expired token on report send).
|
||||
"""
|
||||
record = read_auth()
|
||||
if record is None:
|
||||
return False
|
||||
expiry = _expiry(record)
|
||||
return expiry is not None and expiry > datetime.now(UTC)
|
||||
|
||||
|
||||
def write_auth(email: str, token: str, verified_at: str) -> None:
|
||||
"""Atomically persist the auth record with 0600 permissions."""
|
||||
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps({"email": email, "token": token, "verified_at": verified_at})
|
||||
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(AUTH_PATH)
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.chmod(0o600)
|
||||
|
||||
|
||||
def forget() -> None:
|
||||
"""Delete the stored auth record. No-op if it is absent."""
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
# --- relay client -----------------------------------------------------------
|
||||
|
||||
|
||||
def _app_url() -> str:
|
||||
return load_settings().viewer.app_url.rstrip("/")
|
||||
|
||||
|
||||
def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int, dict[str, Any]]:
|
||||
"""POST JSON to the relay. Returns (status, parsed body).
|
||||
|
||||
Raises RelayError("unavailable") for network/transport failures. HTTP
|
||||
error responses (4xx/5xx) are returned as (status, body) for the caller to
|
||||
map, not raised.
|
||||
"""
|
||||
url = f"{_app_url()}{path}"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
logger.warning("relay request to %s failed: %s", path, exc)
|
||||
raise RelayError("unavailable") from exc
|
||||
return response.status_code, _parse_body(response.content)
|
||||
|
||||
|
||||
def _parse_body(raw: bytes) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(raw or b"{}")
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def otp_start(email: str) -> None:
|
||||
"""Ask the relay to email a verification code. Raises RelayError on failure."""
|
||||
status, data = _post_json("/api/oss/otp/start", {"email": email}, timeout=_OTP_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
# The relay uses 400 both for a malformed address and, separately, to
|
||||
# reject a free/personal email domain (it wants a work email).
|
||||
if data.get("error") == "work_email_required":
|
||||
raise RelayError("work_email_required")
|
||||
raise RelayError("invalid_email")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def otp_verify(email: str, code: str) -> dict[str, Any]:
|
||||
"""Verify a code. Returns ``{token, email, expires_at}`` or raises RelayError."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/otp/verify",
|
||||
{"email": email, "code": code},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200 and isinstance(data.get("token"), str):
|
||||
# A token with no usable expiry cannot unlock history locally (the gate
|
||||
# fails closed), so treat such a response as a failed verification rather
|
||||
# than reporting success and then leaving the user stuck unverified.
|
||||
if parse_expiry(data.get("expires_at")) is None:
|
||||
raise RelayError("unavailable")
|
||||
return data
|
||||
if status == 403:
|
||||
raise RelayError("invalid_code")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def feedback_submit(email: str, message: str) -> None:
|
||||
"""Relay a feedback message + email to Strix. No verification is required;
|
||||
the email is taken as given. Raises RelayError on failure."""
|
||||
status, data = _post_json(
|
||||
"/api/oss/feedback",
|
||||
{"email": email, "message": message},
|
||||
timeout=_OTP_TIMEOUT,
|
||||
)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 429:
|
||||
raise RelayError("rate_limited")
|
||||
if status == 400:
|
||||
code = data.get("error")
|
||||
if code in ("invalid_email", "invalid_message"):
|
||||
raise RelayError(str(code))
|
||||
raise RelayError("invalid_message")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
def report_send(
|
||||
token: str,
|
||||
pdf_bytes: bytes,
|
||||
filename: str,
|
||||
run_name: str,
|
||||
target: str,
|
||||
) -> None:
|
||||
"""Forward the encrypted PDF to the relay for delivery.
|
||||
|
||||
The report password is NEVER part of this payload; only the encrypted PDF
|
||||
bytes travel to the relay.
|
||||
"""
|
||||
payload = {
|
||||
"token": token,
|
||||
"pdf_base64": base64.b64encode(pdf_bytes).decode("ascii"),
|
||||
"filename": filename,
|
||||
"run_name": run_name,
|
||||
"target": target,
|
||||
}
|
||||
status, _ = _post_json("/api/oss/report/send", payload, timeout=_SEND_TIMEOUT)
|
||||
if status == 200:
|
||||
return
|
||||
if status == 401:
|
||||
raise RelayError("reverify")
|
||||
if status == 413:
|
||||
raise RelayError("too_large")
|
||||
if status == 403:
|
||||
raise RelayError("forbidden")
|
||||
raise RelayError("unavailable")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUTH_PATH",
|
||||
"RelayError",
|
||||
"feedback_submit",
|
||||
"forget",
|
||||
"is_verified",
|
||||
"otp_start",
|
||||
"otp_verify",
|
||||
"read_auth",
|
||||
"report_send",
|
||||
"write_auth",
|
||||
]
|
||||
@@ -1,142 +0,0 @@
|
||||
"""`strix view [<run>]` command: serve a run's viewer UI locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from strix.core.paths import (
|
||||
RUNS_DIR_NAME,
|
||||
latest_run_dir,
|
||||
run_dir_for,
|
||||
run_record_path,
|
||||
runs_base_dir,
|
||||
)
|
||||
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||
from strix.interface.viewer.transcript import read_run_summary
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_view(argv: list[str]) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="strix view",
|
||||
description="Open a local web view of a Strix run (live or finished).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"run",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=f"Run name under ./{RUNS_DIR_NAME} (defaults to the most recent run).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Port to serve on (default: an available ephemeral port).",
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--no-open",
|
||||
action="store_true",
|
||||
help="Do not open the browser automatically.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
console = Console()
|
||||
|
||||
if not bundle_is_built():
|
||||
console.print(
|
||||
"[bold red]Viewer UI is not built.[/]\n"
|
||||
"Build it with: [cyan]cd strix/interface/viewer/frontend && npm ci && npm run build[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
run_dir = _resolve_run_dir(args.run, console)
|
||||
|
||||
httpd, url, token = serve(
|
||||
run_dir,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
)
|
||||
# The tokened URL is what authorizes the browser (steering, report sending,
|
||||
# history). Print it rather than the bare URL so the operator -- and only
|
||||
# the operator -- can open or share an authorized link.
|
||||
open_url = authorized_url(url, token)
|
||||
|
||||
run_name = run_dir.name
|
||||
summary = read_run_summary(run_dir)
|
||||
live = not summary.get("finished", False)
|
||||
|
||||
from strix.telemetry import posthog
|
||||
|
||||
posthog.viewer_opened(source="cli", live=live)
|
||||
|
||||
state_label = "[#eab308]live[/]" if live else "[#22c55e]finished[/]"
|
||||
console.print()
|
||||
console.print(f"Serving [bold white]{run_name}[/] ({state_label}) at:")
|
||||
# Print the URL alone on its own line with soft_wrap so Rich never inserts a
|
||||
# wrap into the (long, tokened) link -- that keeps it selectable/copyable.
|
||||
console.print(f" [#60a5fa]{open_url}[/]", soft_wrap=True)
|
||||
console.print("[dim]This link authorizes the browser; anyone you share it with can steer[/]")
|
||||
console.print("[dim]a live scan and browse history. Press Ctrl-C to stop the viewer.[/]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Viewer stopped.[/]")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def _resolve_run_dir(run: str | None, console: Console) -> Path:
|
||||
if run:
|
||||
run_dir = run_dir_for(run)
|
||||
if not run_record_path(run_dir).is_file():
|
||||
_fail_no_run(console, requested=run)
|
||||
return run_dir
|
||||
|
||||
latest = latest_run_dir()
|
||||
if latest is None:
|
||||
_fail_no_run(console, requested=None)
|
||||
return latest
|
||||
|
||||
|
||||
def _fail_no_run(console: Console, *, requested: str | None) -> NoReturn:
|
||||
base = runs_base_dir()
|
||||
available = (
|
||||
sorted(
|
||||
(child.name for child in base.iterdir() if run_record_path(child).is_file()),
|
||||
reverse=True,
|
||||
)
|
||||
if base.is_dir()
|
||||
else []
|
||||
)
|
||||
|
||||
if requested:
|
||||
console.print(f"[bold red]No run named '{requested}' under ./{RUNS_DIR_NAME}.[/]")
|
||||
else:
|
||||
console.print(f"[bold red]No runs found under ./{RUNS_DIR_NAME}.[/]")
|
||||
|
||||
if available:
|
||||
console.print("Available runs:")
|
||||
for name in available[:20]:
|
||||
console.print(f" [cyan]{name}[/]")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
__all__ = ["run_view"]
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="./logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
-4219
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "strix-viewer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^8.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,789 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Mail,
|
||||
ChevronDown,
|
||||
Radar,
|
||||
Rocket,
|
||||
ArrowUpRight,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/issues";
|
||||
import { SEVERITY_COLORS } from "@/types/issues";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import VulnerabilityDetail from "@/components/vulnerability/VulnerabilityDetail";
|
||||
import { ContentSection } from "@/components/vulnerability/ContentSection";
|
||||
import { IssueSeveritySummary } from "@/components/IssueSeveritySummary";
|
||||
import AgentGraph from "@/components/live/AgentGraph";
|
||||
import { buildGraphAgents } from "@/components/live/AgentTranscript";
|
||||
import AgentDetailModal from "@/components/live/AgentDetailModal";
|
||||
import { ScanPromptComposer } from "@/components/live/ScanPromptComposer";
|
||||
import { severityCounts, type ParsedRunSummary } from "@/lib/local-run-parser";
|
||||
import {
|
||||
fetchAll,
|
||||
fetchAuthStatus,
|
||||
fetchCapabilities,
|
||||
fetchRunSummary,
|
||||
fetchRuns,
|
||||
fetchTranscript,
|
||||
fetchVulnerabilities,
|
||||
forgetAuth,
|
||||
type AuthStatus,
|
||||
type LoadedRun,
|
||||
type RunsPayload,
|
||||
} from "@/data/serverSource";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import PastRunsView from "@/components/PastRunsView";
|
||||
import EmailReportView from "@/components/EmailReportView";
|
||||
import { RunDetails } from "@/components/RunDetails";
|
||||
import { TrustToast } from "@/components/TrustToast";
|
||||
import FeedbackView from "@/components/FeedbackView";
|
||||
import { ProInlineCta } from "@/components/ProCta";
|
||||
|
||||
export type View = "overview" | "issues" | "agents" | "history" | "email" | "feedback";
|
||||
|
||||
const TRUST_BANNER =
|
||||
"Your findings stay on your machine. They're rendered here locally in your browser and never uploaded or stored by Strix.";
|
||||
|
||||
const SEVERITY_ORDER: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
const POLL_MS = 500;
|
||||
|
||||
export default function App() {
|
||||
const [activeRun, setActiveRun] = useState<string | null>(null);
|
||||
const [run, setRun] = useState<LoadedRun | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [view, setView] = useState<View>("overview");
|
||||
const [auth, setAuth] = useState<AuthStatus | null>(null);
|
||||
const [runs, setRuns] = useState<RunsPayload | null>(null);
|
||||
const [emailPurpose, setEmailPurpose] = useState<"report" | "verify">("report");
|
||||
const [emailSkipDisclosure, setEmailSkipDisclosure] = useState(false);
|
||||
// Whether this viewer can steer a live scan (true only inside the in-TUI
|
||||
// launcher that shares the running scan's coordinator + event loop).
|
||||
const [canSteer, setCanSteer] = useState(false);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
try {
|
||||
setAuth(await fetchAuthStatus());
|
||||
} catch {
|
||||
/* auth status is best-effort; the launched run stays viewable */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshRuns = useCallback(async () => {
|
||||
try {
|
||||
setRuns(await fetchRuns());
|
||||
} catch {
|
||||
/* history list is best-effort */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
// Capabilities never change over a session, so fetch once on mount.
|
||||
fetchCapabilities()
|
||||
.then((caps) => setCanSteer(caps.can_steer))
|
||||
.catch(() => {
|
||||
/* absence of steering is the safe default */
|
||||
});
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
// Live polling, scoped to the active run. Re-runs when the active run changes
|
||||
// so switching to a past run (?run=<name>) reloads its data; a finished run
|
||||
// does a single full fetch and stops.
|
||||
const finishedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
finishedRef.current = false;
|
||||
|
||||
const schedule = () => {
|
||||
timer = setTimeout(tick, POLL_MS);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const { summary, raw, finished } = await fetchRunSummary(activeRun);
|
||||
if (cancelled) return;
|
||||
if (finished && !finishedRef.current) {
|
||||
finishedRef.current = true;
|
||||
const full = await fetchAll(activeRun);
|
||||
if (!cancelled) setRun(full);
|
||||
return; // stop polling
|
||||
}
|
||||
const [transcript, vulnerabilities] = await Promise.all([
|
||||
fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })),
|
||||
fetchVulnerabilities(summary.runId, activeRun).catch(() => [] as Vulnerability[]),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRun((prev) => ({
|
||||
summary,
|
||||
raw,
|
||||
finished,
|
||||
transcript,
|
||||
vulnerabilities,
|
||||
reportMarkdown: prev?.reportMarkdown ?? null,
|
||||
}));
|
||||
schedule();
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const full = await fetchAll(activeRun);
|
||||
if (cancelled) return;
|
||||
setRun(full);
|
||||
if (full.finished) {
|
||||
finishedRef.current = true;
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : "Could not load run data.");
|
||||
schedule();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [activeRun]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => (run ? severityCounts(run.vulnerabilities) : null),
|
||||
[run]
|
||||
);
|
||||
const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null;
|
||||
const agentCount = run?.transcript.agents.length ?? 0;
|
||||
const verified = auth?.verified === true;
|
||||
|
||||
// Per-run guard for the default view: land on Agents while a scan is live,
|
||||
// Overview once it finishes. Applied at most once per run and never once the
|
||||
// user has navigated manually (userSetView flips the guard).
|
||||
const initialViewAppliedRef = useRef(false);
|
||||
|
||||
// Reset the guard whenever the active run changes so the newly selected run
|
||||
// gets its own default.
|
||||
useEffect(() => {
|
||||
initialViewAppliedRef.current = false;
|
||||
}, [activeRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialViewAppliedRef.current || !run) return;
|
||||
if (run.finished) {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("overview");
|
||||
} else if (agentCount > 0) {
|
||||
// Live and agents have appeared: default to the agent graph. If it is
|
||||
// live but no agents exist yet, wait (do not apply, do not set the flag).
|
||||
initialViewAppliedRef.current = true;
|
||||
setView("agents");
|
||||
}
|
||||
}, [run, agentCount]);
|
||||
|
||||
// User-initiated navigation: mark the default guard applied so the per-run
|
||||
// default effect never yanks the user off the view they chose.
|
||||
const userSetView = useCallback((v: View) => {
|
||||
initialViewAppliedRef.current = true;
|
||||
setView(v);
|
||||
}, []);
|
||||
|
||||
const selectRun = useCallback((name: string) => {
|
||||
setActiveRun(name);
|
||||
setSelectedId(null);
|
||||
setRun(null);
|
||||
setError(null);
|
||||
// Reset the guard so the per-run default applies to the newly selected run.
|
||||
initialViewAppliedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const goEmail = useCallback((skipDisclosure: boolean, surface: string) => {
|
||||
trackCta("email_report", surface);
|
||||
setEmailPurpose("report");
|
||||
setEmailSkipDisclosure(skipDisclosure);
|
||||
userSetView("email");
|
||||
}, [userSetView]);
|
||||
|
||||
// Sidebar entry keeps the disclosure (first place those users see it);
|
||||
const openEmail = useCallback(() => goEmail(false, "sidebar"), [goEmail]);
|
||||
// the Overview CTA already states the tradeoff, so it starts the flow directly.
|
||||
const openEmailFromOverview = useCallback(() => goEmail(true, "overview"), [goEmail]);
|
||||
|
||||
const openHistory = useCallback(() => {
|
||||
void refreshRuns();
|
||||
userSetView("history");
|
||||
}, [refreshRuns, userSetView]);
|
||||
|
||||
const onPastRunsVerified = useCallback(async () => {
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
const onForget = useCallback(async () => {
|
||||
await forgetAuth();
|
||||
await refreshAuth();
|
||||
await refreshRuns();
|
||||
}, [refreshAuth, refreshRuns]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white flex">
|
||||
<Sidebar
|
||||
view={view}
|
||||
onSelectView={(v) => {
|
||||
// Clicking a sidebar view always lands on that section's top level,
|
||||
// so leaving a specific issue's detail view and clicking "Issues"
|
||||
// returns to the full findings list.
|
||||
setSelectedId(null);
|
||||
if (v === "history") openHistory();
|
||||
else userSetView(v);
|
||||
}}
|
||||
issuesCount={run?.vulnerabilities.length ?? 0}
|
||||
agentCount={agentCount}
|
||||
runCount={runs?.count ?? 0}
|
||||
finished={run?.finished ?? false}
|
||||
verified={verified}
|
||||
email={auth?.email ?? null}
|
||||
onOpenEmail={openEmail}
|
||||
onOpenHistory={openHistory}
|
||||
onForget={() => void onForget()}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-[#222]">
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-4 flex items-center gap-1.5">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "topbar")}
|
||||
className="flex items-center gap-1.5 opacity-90 transition-opacity hover:opacity-100 lg:hidden"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<img src="./logo.png" alt="Strix" className="w-10 h-8 object-cover" />
|
||||
<div className="text-base text-white font-medium tracking-tight">Strix</div>
|
||||
</a>
|
||||
{run && <LiveIndicator finished={run.finished} />}
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{verified && runs && !runs.locked && runs.runs.length > 0 && (
|
||||
<RunSwitcher
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
launchedName={runTitle(run?.summary.targets[0] ?? null, run?.summary.runName ?? run?.summary.runId ?? "Current run")}
|
||||
onSelect={selectRun}
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "run_in_cloud")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("run_in_cloud", "topbar")}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Run in the cloud
|
||||
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[88rem] mx-auto px-3 sm:px-6 py-8 sm:py-12 space-y-6">
|
||||
{error && !run && view !== "history" && view !== "email" && (
|
||||
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyed wrapper: re-mounts on every view / finding / run change so the
|
||||
page-in transition replays. */}
|
||||
<div
|
||||
key={`${activeRun ?? "launched"}:${view}:${selectedId ?? ""}`}
|
||||
className="animate-page-in space-y-6"
|
||||
>
|
||||
{view === "email" ? (
|
||||
<EmailReportView
|
||||
activeRun={activeRun}
|
||||
auth={auth}
|
||||
purpose={emailPurpose}
|
||||
skipDisclosure={emailSkipDisclosure}
|
||||
onAuthChanged={() => {
|
||||
void refreshAuth();
|
||||
void refreshRuns();
|
||||
}}
|
||||
onExit={(dest) => setView(dest === "history" ? "history" : "overview")}
|
||||
/>
|
||||
) : view === "feedback" ? (
|
||||
<FeedbackView
|
||||
defaultEmail={auth?.email ?? null}
|
||||
onExit={(dest) => setView(dest)}
|
||||
/>
|
||||
) : view === "history" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-5 h-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Past runs</h1>
|
||||
</div>
|
||||
<PastRunsView
|
||||
runs={runs}
|
||||
activeRun={activeRun}
|
||||
onSelectRun={selectRun}
|
||||
onVerified={() => void onPastRunsVerified()}
|
||||
/>
|
||||
</div>
|
||||
) : !run && !error ? (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-10 text-center">
|
||||
<div className="w-6 h-6 mx-auto mb-3 rounded-full border-2 border-[#333] border-t-white animate-spin" />
|
||||
<p className="text-sm text-[#888]">Loading run data…</p>
|
||||
</div>
|
||||
) : run && counts ? (
|
||||
<>
|
||||
<SummaryHeader summary={run.summary} />
|
||||
|
||||
{/* Tab strip: shown on small screens where the sidebar is hidden. */}
|
||||
<div className="flex gap-5 border-b border-[#2a2a2a] lg:hidden">
|
||||
<TabButton active={view === "overview"} onClick={() => userSetView("overview")}>
|
||||
Pentest Overview
|
||||
</TabButton>
|
||||
<TabButton active={view === "issues"} onClick={() => userSetView("issues")}>
|
||||
Issues{run.vulnerabilities.length > 0 ? ` (${run.vulnerabilities.length})` : ""}
|
||||
</TabButton>
|
||||
{agentCount > 0 && (
|
||||
<TabButton active={view === "agents"} onClick={() => userSetView("agents")}>
|
||||
Agents ({agentCount})
|
||||
</TabButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "overview" ? (
|
||||
<OverviewTab
|
||||
summary={run.summary}
|
||||
counts={counts}
|
||||
total={run.vulnerabilities.length}
|
||||
reportMarkdown={run.reportMarkdown}
|
||||
raw={run.raw}
|
||||
finished={run.finished}
|
||||
onOpenEmail={openEmailFromOverview}
|
||||
/>
|
||||
) : view === "agents" && agentCount > 0 ? (
|
||||
<AgentsTab run={run} canSteer={canSteer} />
|
||||
) : selected ? (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to all findings
|
||||
</button>
|
||||
<VulnerabilityDetail vulnerability={selected} />
|
||||
</div>
|
||||
) : (
|
||||
<FindingsList
|
||||
vulnerabilities={run.vulnerabilities}
|
||||
finished={run.finished}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrustToast message={TRUST_BANNER} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunSwitcher({
|
||||
runs,
|
||||
activeRun,
|
||||
launchedName,
|
||||
onSelect,
|
||||
}: {
|
||||
runs: RunsPayload;
|
||||
activeRun: string | null;
|
||||
launchedName: string;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeEntry = runs.runs.find((r) => r.name === activeRun);
|
||||
const current = activeEntry ? runTitle(activeEntry.target, activeEntry.name) : launchedName;
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
aria-label="Switch pentest"
|
||||
className="flex items-center gap-2 rounded-lg border border-[#3a3a3a] bg-[rgba(255,255,255,0.05)] px-3 py-2 text-sm text-white transition-colors hover:border-[#555] hover:bg-[rgba(255,255,255,0.09)]"
|
||||
>
|
||||
<History className="h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<span className="flex-shrink-0 text-[#888]">Pentest</span>
|
||||
<span className="max-w-[260px] truncate font-medium">{current}</span>
|
||||
<ChevronDown className="h-4 w-4 flex-shrink-0 text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 z-50 mt-2 max-h-96 w-96 overflow-y-auto rounded-xl py-1.5 shadow-2xl"
|
||||
style={{ border: "1px solid #3a3a3a", background: "#0a0a0a" }}
|
||||
>
|
||||
<div className="border-b border-[#222] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[#666]">
|
||||
Switch pentest
|
||||
</div>
|
||||
{runs.runs.map((r) => {
|
||||
const active = r.name === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={r.name}
|
||||
onMouseDown={() => onSelect(r.name)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[rgba(255,255,255,0.06)] ${
|
||||
active ? "bg-[rgba(255,255,255,0.04)] text-white" : "text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{runTitle(r.target, r.name)}</span>
|
||||
{r.target && <span className="block truncate font-mono text-xs text-[#666]">{r.target}</span>}
|
||||
</span>
|
||||
{active && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-emerald-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveIndicator({ finished }: { finished: boolean }) {
|
||||
if (finished) {
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-[#888]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#555]" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75 animate-ping" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
Live
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string | null {
|
||||
if (seconds == null) return null;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function SummaryHeader({ summary }: { summary: ParsedRunSummary }) {
|
||||
const duration = formatDuration(summary.durationSeconds);
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{runTitle(summary.targets[0] ?? null, summary.runName ?? summary.runId ?? "Pentest results")}
|
||||
</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[#888]">
|
||||
{summary.targets.length > 0 && (
|
||||
<span className="font-mono text-[#aaa]">{summary.targets.join(", ")}</span>
|
||||
)}
|
||||
{summary.scanMode && <Meta label={summary.scanMode} />}
|
||||
{duration && <Meta label={duration} />}
|
||||
{summary.status && <Meta label={summary.status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label }: { label: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-[#333]">·</span>
|
||||
<span className="capitalize">{label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingsList({
|
||||
vulnerabilities,
|
||||
finished,
|
||||
onSelect,
|
||||
}: {
|
||||
vulnerabilities: Vulnerability[];
|
||||
finished: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const sorted = [...vulnerabilities].sort(
|
||||
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
|
||||
);
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
{finished ? "No findings in this run." : "No findings yet. The pentest is still running…"}
|
||||
</div>
|
||||
{finished && (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-medium text-white">Stay ahead of new exposures</p>
|
||||
<p className="mt-0.5 mb-3 text-xs text-[#666]">
|
||||
Attack surface monitoring catches new exposures for your org over time.
|
||||
</p>
|
||||
<ProInlineCta
|
||||
label="Attack surface monitoring"
|
||||
desc="Continuous coverage for your whole org."
|
||||
slug="asm"
|
||||
surface="empty_state"
|
||||
icon={Radar}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => onSelect(v.id)}
|
||||
className="animate-card-in cursor-pointer w-full text-left rounded-lg border border-[#222] hover:border-[#444] bg-[rgba(255,255,255,0.02)] px-4 py-3 transition-colors flex items-center gap-3"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${getSeverityDot(v.severity)}`} aria-hidden="true" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-white truncate">{v.title}</span>
|
||||
{v.target && (
|
||||
<span className="block text-xs text-[#666] font-mono truncate">{v.target}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full border capitalize ${SEVERITY_COLORS[v.severity]}`}
|
||||
>
|
||||
{v.severity}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip a single leading markdown heading (report sections embed their own). */
|
||||
function stripLeadingHeading(md: string): string {
|
||||
return md.replace(/^\s*#{1,6}[ \t]+.*(?:\r?\n)+/, "").trimStart();
|
||||
}
|
||||
|
||||
function dedupeHeadings(md: string): string {
|
||||
const out: string[] = [];
|
||||
let lastHeading: string | null = null;
|
||||
for (const line of md.split("\n")) {
|
||||
const m = line.match(/^#{1,6}\s+(.*)$/);
|
||||
if (m) {
|
||||
const norm = m[1].trim().toLowerCase();
|
||||
if (norm === lastHeading) continue;
|
||||
lastHeading = norm;
|
||||
} else if (line.trim() !== "") {
|
||||
lastHeading = null;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Primary local CTA: email an encrypted PDF. Verify-email affordance, no lock. */
|
||||
function EmailReportCta({ onOpenEmail }: { onOpenEmail: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpenEmail}
|
||||
className="group w-full cursor-pointer rounded-xl border border-emerald-500/25 bg-emerald-500/[0.06] p-4 text-left transition-colors hover:border-emerald-500/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ border: "1px solid rgba(16,185,129,0.3)", background: "rgba(16,185,129,0.08)" }}
|
||||
>
|
||||
<Mail className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white">Email an encrypted PDF report of this run</p>
|
||||
<p className="mt-0.5 text-xs text-[#888]">
|
||||
Encrypted with a key only you can see, email verified with a one-time code before sending.
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex-shrink-0 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity group-hover:opacity-90">
|
||||
Export report to PDF
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
summary,
|
||||
counts,
|
||||
total,
|
||||
reportMarkdown,
|
||||
raw,
|
||||
finished,
|
||||
onOpenEmail,
|
||||
}: {
|
||||
summary: ParsedRunSummary;
|
||||
counts: Record<VulnerabilitySeverity, number>;
|
||||
total: number;
|
||||
reportMarkdown: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
onOpenEmail: () => void;
|
||||
}) {
|
||||
const sections = (
|
||||
[
|
||||
["Executive Summary", summary.executiveSummary],
|
||||
["Technical Analysis", summary.technicalAnalysis],
|
||||
["Methodology", summary.methodology],
|
||||
["Recommendations", summary.recommendations],
|
||||
] as const
|
||||
)
|
||||
.filter(([, content]) => !!content)
|
||||
.map(([title, content]) => ({ title, content: stripLeadingHeading(content as string) }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="animate-card-in">
|
||||
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
|
||||
</div>
|
||||
|
||||
{total > 0 && (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<IssueSeveritySummary findings={{ total, ...counts }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary CTA: the one primary on Overview. Hidden until the run is
|
||||
finished, since a live scan would only email a partial report. */}
|
||||
{finished && (
|
||||
<div className="animate-card-in">
|
||||
<EmailReportCta onOpenEmail={onOpenEmail} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5 space-y-8">
|
||||
{sections.map((s) => (
|
||||
<ContentSection key={s.title} title={s.title} content={s.content} />
|
||||
))}
|
||||
</div>
|
||||
) : reportMarkdown ? (
|
||||
<div className="animate-card-in rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<ContentSection content={dedupeHeadings(reportMarkdown)} />
|
||||
</div>
|
||||
) : (
|
||||
total === 0 && (
|
||||
<p className="text-sm text-[#888]">No summary available for this run yet.</p>
|
||||
)
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer relative pb-2.5 text-sm font-semibold transition-colors ${
|
||||
active ? "text-white" : "text-[#666] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
{active && <span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
|
||||
const { agents, events } = run.transcript;
|
||||
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
|
||||
// Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
|
||||
|
||||
// Live steering is only possible in-process (canSteer) while the scan runs.
|
||||
const steerable = canSteer && !run.finished;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Agent graph</h2>
|
||||
<span className="text-xs text-[#666]">
|
||||
{agents.length} agent{agents.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 mb-4 text-xs text-[#666]">
|
||||
Click an agent to open its full transcript.
|
||||
</p>
|
||||
<div className="h-[480px] rounded-lg border border-[#1a1a1a] overflow-hidden">
|
||||
<AgentGraph
|
||||
agents={graphAgents}
|
||||
selectedAgentId={selectedId}
|
||||
onSelectAgent={(id) => setSelectedId(id)}
|
||||
eventsLoaded
|
||||
eventsEmpty={graphAgents.size === 0}
|
||||
scanCompleted={run.finished}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live steering: only in-process while the scan runs. Otherwise omitted. */}
|
||||
{steerable && <ScanPromptComposer agents={agents} />}
|
||||
|
||||
{/* Re-run always routes to Strix Cloud. */}
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<p className="text-sm font-semibold text-white">Run this pentest with more depth</p>
|
||||
<p className="mt-0.5 text-xs text-[#666]">Re-run this pentest on managed infra in the cloud.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2.5">
|
||||
<ProInlineCta
|
||||
label="Re-run in Strix Pro with more depth"
|
||||
desc="Run this pentest on managed infra with more depth."
|
||||
slug="live_scan"
|
||||
surface="agents"
|
||||
icon={Rocket}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentDetailModal
|
||||
open={selectedAgent !== null}
|
||||
agent={selectedAgent}
|
||||
events={events}
|
||||
steerable={steerable}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Extracted ProviderIcon from strix-app's AddRepositoryDialog. The dialog itself
|
||||
// (and its next/link dependency) is dropped; the IssueSidebar only needs this SVG
|
||||
// switch to badge a finding's source-control provider. Web-app targets resolve to
|
||||
// provider === null and never reach here (they render a globe icon instead).
|
||||
import { Github, Gitlab } from "lucide-react";
|
||||
|
||||
function BitbucketIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
||||
<path d="M2.65 3a.72.72 0 0 0-.72.83l2.86 17.39a.98.98 0 0 0 .96.82h13.72a.72.72 0 0 0 .72-.6l2.86-17.4A.72.72 0 0 0 22.3 3H2.65Zm12.1 12.53H9.3L8.06 8.9h7.8l-1.11 6.63Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderIcon({ provider, className }: { provider: string; className?: string }) {
|
||||
const cls = className ?? "w-4 h-4";
|
||||
if (provider === "gitlab") return <Gitlab className={`${cls} text-orange-400`} />;
|
||||
if (provider === "bitbucket") return <BitbucketIcon className={`${cls} text-blue-400`} />;
|
||||
return <Github className={`${cls} text-white`} />;
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Mail, ShieldCheck, Lock, Copy, Check, Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
otpStart,
|
||||
otpVerify,
|
||||
sendReport,
|
||||
type AuthStatus,
|
||||
} from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* The email-report / email-verification flow rendered as its own page (not a
|
||||
* modal, so it never floats over another surface). Report mode ends in the
|
||||
* one-time password panel; verify mode just confirms the email and returns to
|
||||
* the caller. The page unmounts when you navigate away, so state resets each
|
||||
* time it is opened.
|
||||
*/
|
||||
|
||||
type Step = "disclosure" | "email" | "code" | "sending" | "password";
|
||||
|
||||
interface EmailReportViewProps {
|
||||
activeRun: string | null;
|
||||
auth: AuthStatus | null;
|
||||
purpose: "report" | "verify";
|
||||
/**
|
||||
* Skip the report disclosure and start the flow directly (used by the
|
||||
* Overview CTA, which already states the tradeoff). Unverified users land on
|
||||
* the email step; already-verified users send immediately.
|
||||
*/
|
||||
skipDisclosure?: boolean;
|
||||
/** Refresh auth + runs after a successful verify (lifts state to App). */
|
||||
onAuthChanged: () => void;
|
||||
/** Leave this page (report "Done" -> overview; verify success -> history). */
|
||||
onExit: (dest: "overview" | "history") => void;
|
||||
}
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
const SEND_ERRORS: Record<string, string> = {
|
||||
forbidden: "This email was unsubscribed from Strix, so we cannot send to it.",
|
||||
too_large: "This report is too large to email. Try a smaller run.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailReportView({
|
||||
activeRun,
|
||||
auth,
|
||||
purpose,
|
||||
skipDisclosure = false,
|
||||
onAuthChanged,
|
||||
onExit,
|
||||
}: EmailReportViewProps) {
|
||||
const verified = auth?.verified === true;
|
||||
const verifyOnly = purpose === "verify";
|
||||
// Verify mode (and the Overview CTA, which skips the disclosure) start on the
|
||||
// email step; a verified user who skips the disclosure sends immediately.
|
||||
const [step, setStep] = useState<Step>(() => {
|
||||
if (verifyOnly) return "email";
|
||||
if (skipDisclosure) return verified ? "sending" : "email";
|
||||
return "disclosure";
|
||||
});
|
||||
const [email, setEmail] = useState(auth?.email ?? "");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sentTo, setSentTo] = useState("");
|
||||
const autoSentRef = useRef(false);
|
||||
|
||||
const doSend = async () => {
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await sendReport(activeRun);
|
||||
if (result.ok) {
|
||||
track("report_sent");
|
||||
setPassword(result.password);
|
||||
setFilename(result.filename);
|
||||
setStep("password");
|
||||
return;
|
||||
}
|
||||
if (result.error === "reverify" || result.error === "unverified") {
|
||||
setNotice("Your verification expired. Enter your email to verify again.");
|
||||
setStep("email");
|
||||
return;
|
||||
}
|
||||
setError(SEND_ERRORS[result.error] ?? "Could not send the report. Try again.");
|
||||
setStep("disclosure");
|
||||
};
|
||||
|
||||
const startFlow = () => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
if (verified) void doSend();
|
||||
else setStep("email");
|
||||
};
|
||||
|
||||
// A verified user who skipped the disclosure (Overview CTA) sends on arrival.
|
||||
useEffect(() => {
|
||||
if (!verifyOnly && skipDisclosure && verified && !autoSentRef.current) {
|
||||
autoSentRef.current = true;
|
||||
void doSend();
|
||||
}
|
||||
// Run once on mount; the page remounts fresh each time it is opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose });
|
||||
setSentTo(result.email);
|
||||
onAuthChanged();
|
||||
if (verifyOnly) onExit("history");
|
||||
else void doSend();
|
||||
};
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard may be unavailable; the password is visible to copy manually */
|
||||
}
|
||||
};
|
||||
|
||||
const confirmationEmail = sentTo || auth?.email || email.trim();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit(verifyOnly ? "history" : "overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{verifyOnly ? "Back to past runs" : "Back to results"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{verifyOnly ? "Verify your email" : "Export report to PDF"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
{verifyOnly
|
||||
? "We send a one-time code to confirm it is you."
|
||||
: "Verified by a one-time code sent to your email"}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && step !== "password" && (
|
||||
<p className="mb-4 text-xs text-[#888]">{notice}</p>
|
||||
)}
|
||||
|
||||
{step === "disclosure" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-2.5 rounded-lg p-3.5"
|
||||
style={{ border: "1px solid #222", background: "rgba(255,255,255,0.02)" }}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
We email an <span className="text-white">encrypted PDF</span>. Nothing else leaves your machine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Lock className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#888]" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">
|
||||
Only you hold the password; Strix can't read it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startFlow}
|
||||
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
Export report
|
||||
</button>
|
||||
{verified && auth?.email && (
|
||||
<p className="text-center text-xs text-[#666]">Sending to {auth.email}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "email" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "code" && (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{verifyOnly ? "Verify" : "Verify and send"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === "sending" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" aria-hidden="true" />
|
||||
<p className="text-sm text-[#aaa]">Generating and encrypting locally...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "password" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
|
||||
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs text-emerald-200">
|
||||
Sent to {confirmationEmail}. Open the attached PDF with this password.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your one-time password</span>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-black p-3"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
<code className="flex-1 break-all font-mono text-base text-white">{password}</code>
|
||||
<button
|
||||
onClick={copyPassword}
|
||||
className="flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs text-[#aaa] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-white"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#666]">
|
||||
Save this now. Strix never stores it, so we cannot show it again. File:{" "}
|
||||
<span className="font-mono text-[#888]">{filename}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="w-full cursor-pointer rounded-lg px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { otpStart, otpVerify } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Compact inline email -> 6-digit-code verify flow. Unlike EmailReportView this
|
||||
* has no page chrome, no report send, and no password panel: it just confirms
|
||||
* the email so the past-runs list can unlock in place. On success it calls
|
||||
* `onVerified` (the parent refreshes auth + runs).
|
||||
*/
|
||||
|
||||
const OTP_START_ERRORS: Record<string, string> = {
|
||||
work_email_required: "Please use your work email, not a personal one.",
|
||||
rate_limited: "Too many requests. Wait a minute and try again.",
|
||||
invalid_email: "That email does not look right. Check it and try again.",
|
||||
unavailable: "The email service is unavailable right now. Try again shortly.",
|
||||
};
|
||||
|
||||
// A small set of common personal providers for instant client-side feedback.
|
||||
// The relay is authoritative (it checks the full free-email-domains list).
|
||||
const COMMON_FREE_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "yahoo.com", "ymail.com", "outlook.com",
|
||||
"hotmail.com", "live.com", "icloud.com", "me.com", "aol.com", "proton.me",
|
||||
"protonmail.com", "gmx.com", "mail.com",
|
||||
]);
|
||||
|
||||
export default function EmailVerifyInline({ onVerified }: { onVerified: () => void }) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const submitEmail = async () => {
|
||||
const value = email.trim();
|
||||
if (!value) {
|
||||
setError("Enter your email to continue.");
|
||||
return;
|
||||
}
|
||||
const domain = value.slice(value.lastIndexOf("@") + 1).toLowerCase();
|
||||
if (COMMON_FREE_DOMAINS.has(domain)) {
|
||||
track("work_email_required");
|
||||
setError(OTP_START_ERRORS.work_email_required);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpStart(value);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
track("email_submitted", { purpose: "verify" });
|
||||
setNotice(`We sent a 6-digit code to ${value}.`);
|
||||
setStep("code");
|
||||
} else {
|
||||
if (result.error === "work_email_required") track("work_email_required");
|
||||
setError(OTP_START_ERRORS[result.error] ?? "Could not send a code. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
const value = code.trim();
|
||||
if (value.length < 4) {
|
||||
setError("Enter the 6-digit code from your email.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await otpVerify(email.trim(), value);
|
||||
setBusy(false);
|
||||
if (!result.verified) {
|
||||
setError("That code did not match. Check it and try again.");
|
||||
return;
|
||||
}
|
||||
track("email_verified", { purpose: "verify" });
|
||||
onVerified();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-5 max-w-sm text-left">
|
||||
{error && (
|
||||
<div className="mb-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && !error && <p className="mb-3 text-xs text-[#888]">{notice}</p>}
|
||||
|
||||
{step === "email" ? (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitEmail();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
<span className="mt-1.5 block text-[11px] text-[#666]">Use your work email.</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Send me a code
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submitCode();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">6-digit code</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
className="w-full rounded-lg bg-black px-3 py-2.5 text-center text-lg font-mono tracking-[0.4em] text-white outline-none transition-colors focus:border-[#444]"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("email");
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}}
|
||||
className="w-full cursor-pointer text-center text-xs text-[#666] transition-colors hover:text-[#aaa]"
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { submitFeedback } from "@/data/serverSource";
|
||||
import type { View } from "@/App";
|
||||
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_email: "That email doesn't look right.",
|
||||
invalid_message: "Please write a little more.",
|
||||
unavailable: "Couldn't send that just now. Try again.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Feedback & support form. Collects a message plus a work email (no
|
||||
* verification — the email is taken as-is) and relays it to Strix via the local
|
||||
* server. Mirrors EmailReportView's centered-card styling and palette.
|
||||
*/
|
||||
export default function FeedbackView({
|
||||
defaultEmail,
|
||||
onExit,
|
||||
}: {
|
||||
defaultEmail: string | null;
|
||||
onExit: (dest: View) => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState(defaultEmail ?? "");
|
||||
const [step, setStep] = useState<"form" | "sending" | "sent">("form");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setStep("sending");
|
||||
setError(null);
|
||||
const result = await submitFeedback(message.trim(), email.trim());
|
||||
if (result.ok) {
|
||||
setStep("sent");
|
||||
return;
|
||||
}
|
||||
setStep("form");
|
||||
setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4">
|
||||
<button
|
||||
onClick={() => onExit("overview")}
|
||||
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to results
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<IoChatbubblesOutline className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
<h1 className="text-2xl font-semibold text-white">Feedback & support</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full rounded-2xl bg-[rgba(255,255,255,0.02)] p-6"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
>
|
||||
{step === "sent" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">Thanks, we got it.</p>
|
||||
<p className="mt-1 text-xs text-[#888]">
|
||||
We read every message. If it needs a reply, we'll reach out to the email you gave.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMessage("");
|
||||
setStep("form");
|
||||
}}
|
||||
className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
|
||||
>
|
||||
Send more feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-[#666]">
|
||||
Bugs, feature requests, or anything else. Tell us what's on your mind.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" aria-hidden="true" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your feedback</span>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={message}
|
||||
maxLength={MAX_MESSAGE}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="What's working, what's not, what you'd love to see…"
|
||||
className="w-full resize-y rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-1.5 block text-xs text-[#888]">Your work email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
className="mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
{step === "sending" ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface IssueSeveritySummaryFindings {
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
interface IssueSeveritySummaryProps {
|
||||
findings: IssueSeveritySummaryFindings;
|
||||
className?: string;
|
||||
/** Noun for the total count (e.g. "issues", "CVEs"). Defaults to "issues". */
|
||||
unit?: string;
|
||||
/** Optional content rendered at the end of the count row (e.g. a KEV badge). */
|
||||
trailing?: React.ReactNode;
|
||||
}
|
||||
|
||||
const SEVERITIES = [
|
||||
{ key: "critical", label: "critical", dotClass: "bg-red-500", textClass: "text-red-500" },
|
||||
{ key: "high", label: "high", dotClass: "bg-orange-500", textClass: "text-orange-500" },
|
||||
{ key: "medium", label: "medium", dotClass: "bg-yellow-500", textClass: "text-yellow-500" },
|
||||
{ key: "low", label: "low", dotClass: "bg-blue-500", textClass: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
export function IssueSeveritySummary({
|
||||
findings,
|
||||
className,
|
||||
unit = "issues",
|
||||
trailing,
|
||||
}: IssueSeveritySummaryProps) {
|
||||
if (findings.total <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-semibold text-white tabular-nums">{findings.total}</span>
|
||||
<span className="text-sm text-[#666]">{unit}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{SEVERITIES.map(({ key, label, dotClass, textClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-1.5">
|
||||
<div className={cn("w-2 h-2 rounded-full", dotClass)} aria-hidden="true" />
|
||||
<span className={cn("text-sm tabular-nums", textClass)}>{count}</span>
|
||||
<span className="text-xs text-[#555]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{trailing ? <div className="flex items-center gap-2">{trailing}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 rounded-full bg-[#222] overflow-hidden flex">
|
||||
{SEVERITIES.map(({ key, dotClass }) => {
|
||||
const count = findings[key];
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn("h-full", dotClass)}
|
||||
style={{ width: `${(count / findings.total) * 100}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { History, ChevronRight, Terminal } from "lucide-react";
|
||||
import type { RunListEntry, RunsPayload, RunSeverityCounts } from "@/data/serverSource";
|
||||
import { runTitle } from "@/lib/target-utils";
|
||||
import { trackCta } from "@/lib/cta";
|
||||
import EmailVerifyInline from "@/components/EmailVerifyInline";
|
||||
|
||||
/**
|
||||
* "Past runs" panel. Unverified users see a tease with the run count and a
|
||||
* verify affordance (the launched run stays fully visible; the CLI
|
||||
* `strix view <name>` still works). Verified users get the full history and can
|
||||
* switch the active run, which threads ?run=<name> through the data fetches.
|
||||
*/
|
||||
|
||||
const SEV = [
|
||||
{ key: "critical", dot: "bg-red-500", text: "text-red-500" },
|
||||
{ key: "high", dot: "bg-orange-500", text: "text-orange-500" },
|
||||
{ key: "medium", dot: "bg-yellow-500", text: "text-yellow-500" },
|
||||
{ key: "low", dot: "bg-blue-500", text: "text-blue-500" },
|
||||
] as const;
|
||||
|
||||
function SeverityChips({ counts }: { counts: RunSeverityCounts }) {
|
||||
const shown = SEV.filter((s) => counts[s.key] > 0);
|
||||
if (shown.length === 0) {
|
||||
return <span className="text-xs text-[#555]">No findings</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{shown.map((s) => (
|
||||
<div key={s.key} className="flex items-center gap-1.5">
|
||||
<span className={`h-2 w-2 rounded-full ${s.dot}`} aria-hidden="true" />
|
||||
<span className={`text-xs tabular-nums ${s.text}`}>{counts[s.key]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time ("just now" / "5m ago" / "3h ago" / "2d ago"), falling back to
|
||||
* the absolute date for anything older than a week (mirrors the pro app).
|
||||
*/
|
||||
function formatTimeAgo(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const normalized = iso.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return formatDate(iso);
|
||||
}
|
||||
|
||||
interface PastRunsViewProps {
|
||||
runs: RunsPayload | null;
|
||||
activeRun: string | null;
|
||||
onSelectRun: (name: string) => void;
|
||||
onVerified: () => void;
|
||||
}
|
||||
|
||||
export default function PastRunsView({
|
||||
runs,
|
||||
activeRun,
|
||||
onSelectRun,
|
||||
onVerified,
|
||||
}: PastRunsViewProps) {
|
||||
const count = runs?.count ?? 0;
|
||||
const [showVerify, setShowVerify] = useState(false);
|
||||
|
||||
if (!runs || runs.locked) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center">
|
||||
<div
|
||||
className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl"
|
||||
style={{ border: "1px solid #2a2a2a", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
<History className="h-5 w-5 text-[#888]" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-white">Browse every run on this machine</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-md text-sm text-[#888]">
|
||||
You have {count} past {count === 1 ? "run" : "runs"} on this machine.
|
||||
</p>
|
||||
{showVerify ? (
|
||||
<>
|
||||
<p className="mx-auto mt-3 max-w-sm text-xs text-[#666]">
|
||||
Verify your email with a one-time code to unlock the full history.
|
||||
</p>
|
||||
<EmailVerifyInline onVerified={onVerified} />
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
trackCta("history_unlock", "past_runs");
|
||||
setShowVerify(true);
|
||||
}}
|
||||
className="mt-4 cursor-pointer rounded-lg bg-white px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
View runs
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-[#555]">
|
||||
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Or open one from the CLI with{" "}
|
||||
<code className="font-mono text-[#888]">strix view <name></code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runs.runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-8 text-center text-sm text-[#888]">
|
||||
No past runs found on this machine yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runs.runs.map((run: RunListEntry) => {
|
||||
const active = run.name === activeRun;
|
||||
const date = formatTimeAgo(run.start_time) ?? formatTimeAgo(run.end_time);
|
||||
const title = runTitle(run.target, run.name);
|
||||
return (
|
||||
<button
|
||||
key={run.name}
|
||||
onClick={() => onSelectRun(run.name)}
|
||||
className={`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
active
|
||||
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
|
||||
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">{title}</span>
|
||||
{active && (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400" style={{ border: "1px solid rgba(16,185,129,0.3)" }}>
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[#666]">
|
||||
{run.scan_mode && <span className="capitalize">{run.scan_mode}</span>}
|
||||
{run.scan_mode && (date || run.status) && <span className="text-[#333]">·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
{date && run.status && <span className="text-[#333]">·</span>}
|
||||
{run.status && <span className="capitalize">{run.status}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<SeverityChips counts={run.severity_counts} />
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-[#555] transition-colors group-hover:text-[#aaa]" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
|
||||
* hover/focus. Plain Tailwind + local state (no radix vendored).
|
||||
*/
|
||||
export function Tooltip({
|
||||
text,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex ${className}`}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
{children}
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-[240px] -translate-x-1/2 rounded-md px-2.5 py-1.5 text-xs text-[#ddd] shadow-lg"
|
||||
style={{ border: "1px solid #2a2a2a", background: "#0a0a0a" }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline CTA button that links out to sign-up in a new tab, with a
|
||||
* hover tooltip one-liner. Used in per-surface rows where a full card is too
|
||||
* heavy.
|
||||
*/
|
||||
export function ProInlineCta({
|
||||
label,
|
||||
desc,
|
||||
slug,
|
||||
icon: Icon,
|
||||
surface,
|
||||
}: {
|
||||
label: string;
|
||||
desc: string;
|
||||
slug: string;
|
||||
icon: React.ElementType;
|
||||
surface?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip text={desc}>
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(slug, surface)}
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-[#222] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-sm text-[#aaa] transition-colors hover:border-[#444] hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-[#888] transition-colors group-hover:text-white" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Info } from "lucide-react";
|
||||
import { formatNumber } from "@/lib/display-number";
|
||||
|
||||
/**
|
||||
* "Run details" card for the Overview tab: the launch configuration the run was
|
||||
* started with (targets, instruction, scope, mode) and its LLM usage + cost.
|
||||
* Everything is read defensively from the raw run.json record, which may be
|
||||
* partial while a scan is still live.
|
||||
*/
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
|
||||
function rec(v: unknown): Rec {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
|
||||
}
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
}
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
function humanize(s: string): string {
|
||||
return s.replace(/_/g, " ");
|
||||
}
|
||||
function cap(s: string | null): string | null {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
function fmtDuration(seconds: number | null): string {
|
||||
if (seconds == null || seconds < 0) return "n/a";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h) return `${h}h ${m}m ${s}s`;
|
||||
if (m) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7rem_1fr] gap-3 items-baseline">
|
||||
<dt className="text-[11px] uppercase tracking-wide text-[#666]">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-sm text-[#ddd]">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunDetails({
|
||||
raw,
|
||||
durationSeconds,
|
||||
}: {
|
||||
raw: Rec;
|
||||
durationSeconds: number | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
// Configuration (launch inputs)
|
||||
const targets = arr(raw.targets_info).map((t) => {
|
||||
const o = rec(t);
|
||||
const display = str(o.original) ?? str(rec(o.details).target_url) ?? "unknown target";
|
||||
const type = str(o.type);
|
||||
return { display, type: type ? humanize(type) : null };
|
||||
});
|
||||
const instruction = str(raw.instruction);
|
||||
const scanMode = cap(str(raw.scan_mode));
|
||||
const scopeMode = str(raw.scope_mode);
|
||||
const diff = rec(raw.diff_scope);
|
||||
const diffActive = diff.active === true;
|
||||
const diffMode = str(diff.mode);
|
||||
const diffBase = str(raw.diff_base);
|
||||
const nonInteractive = raw.non_interactive === true;
|
||||
const localSources = arr(raw.local_sources)
|
||||
.map((x) => {
|
||||
if (typeof x === "string") return x;
|
||||
const o = rec(x);
|
||||
return str(o.source_path) ?? str(o.target_path) ?? "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const status = cap(str(raw.status));
|
||||
|
||||
let scope = scopeMode ?? "auto";
|
||||
if (diffActive) {
|
||||
scope += ` (diff${diffMode ? `: ${diffMode}` : ""}${diffBase ? ` vs ${diffBase}` : ""})`;
|
||||
}
|
||||
|
||||
// Usage & cost
|
||||
const usage = rec(raw.llm_usage);
|
||||
const hasUsage = Object.keys(usage).length > 0;
|
||||
const agents = arr(usage.agents).map(rec);
|
||||
const models = Array.from(
|
||||
new Set(agents.map((a) => str(a.model)).filter((m): m is string => !!m))
|
||||
);
|
||||
const requests = num(usage.requests);
|
||||
const inputTokens = num(usage.input_tokens);
|
||||
const cached = num(rec(arr(usage.input_tokens_details)[0]).cached_tokens);
|
||||
const outputTokens = num(usage.output_tokens);
|
||||
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
|
||||
const totalTokens = num(usage.total_tokens);
|
||||
const cost = num(usage.cost);
|
||||
const subscription = str(raw.auth_mode) === "subscription";
|
||||
|
||||
const sub = (n: number, word: string) => (
|
||||
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full cursor-pointer items-center gap-2 text-left"
|
||||
>
|
||||
<Info className="h-4 w-4 text-[#888]" aria-hidden="true" />
|
||||
<h2 className="text-sm font-semibold text-white">Run details</h2>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="ml-auto h-4 w-4 text-[#666]" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
Configuration
|
||||
</h3>
|
||||
<dl className="space-y-2.5">
|
||||
{targets.length > 0 && (
|
||||
<Field label="Targets">
|
||||
<div className="space-y-1">
|
||||
{targets.map((t, i) => (
|
||||
<div key={i} className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[#ddd]">{t.display}</span>
|
||||
{t.type && (
|
||||
<span className="rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]">
|
||||
{t.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Instruction">
|
||||
{instruction ? (
|
||||
<span className="whitespace-pre-wrap">{instruction}</span>
|
||||
) : (
|
||||
<span className="text-[#666]">None</span>
|
||||
)}
|
||||
</Field>
|
||||
{scanMode && <Field label="Pentest mode">{scanMode}</Field>}
|
||||
<Field label="Scope">{scope}</Field>
|
||||
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
|
||||
{localSources.length > 0 && (
|
||||
<Field label="Local sources">
|
||||
<div className="space-y-0.5 font-mono text-[#ddd]">
|
||||
{localSources.map((s, i) => (
|
||||
<div key={i}>{s}</div>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{status && <Field label="Status">{status}</Field>}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
|
||||
Usage & cost
|
||||
</h3>
|
||||
{hasUsage ? (
|
||||
<dl className="space-y-2.5 tabular-nums">
|
||||
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
|
||||
{subscription && (
|
||||
<Field label="Provider">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]">
|
||||
ChatGPT subscription
|
||||
</span>
|
||||
</span>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
|
||||
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
|
||||
{inputTokens != null && (
|
||||
<Field label="Input tokens">
|
||||
{formatNumber(inputTokens)}
|
||||
{cached != null && sub(cached, "cached")}
|
||||
</Field>
|
||||
)}
|
||||
{outputTokens != null && (
|
||||
<Field label="Output tokens">
|
||||
{formatNumber(outputTokens)}
|
||||
{reasoning != null && sub(reasoning, "reasoning")}
|
||||
</Field>
|
||||
)}
|
||||
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
|
||||
{subscription ? (
|
||||
<Field label="Cost">
|
||||
<span className="text-[#22c55e]">$0.00</span>
|
||||
<span className="text-[#666]"> (subscription)</span>
|
||||
</Field>
|
||||
) : (
|
||||
cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>
|
||||
)}
|
||||
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-[#666]">Not available yet.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RunDetails;
|
||||
@@ -1,435 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Users,
|
||||
History,
|
||||
Mail,
|
||||
LogOut,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { LuGitPullRequestArrow } from "react-icons/lu";
|
||||
import { VscExtensions } from "react-icons/vsc";
|
||||
import { IoChatbubblesOutline } from "react-icons/io5";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { UpgradeModal } from "@/components/UpgradeModal";
|
||||
import type { View } from "@/App";
|
||||
|
||||
/**
|
||||
* Persistent left rail: a black rail with a right hairline border, an
|
||||
* account-switcher-style header, a single ungrouped list of h-9 nav rows (36px
|
||||
* icon slot, 14px label, rgba(255,255,255,0.12) active fill), a hairline
|
||||
* separator, and a user footer. Drag the right edge to resize; drag past the
|
||||
* collapse threshold to hide it, then click the left pull-zone to bring it back.
|
||||
*/
|
||||
|
||||
const MIN_WIDTH = 160;
|
||||
const DEFAULT_WIDTH = 260;
|
||||
const MAX_WIDTH = 400;
|
||||
const COLLAPSE_THRESHOLD = 140;
|
||||
|
||||
const WIDTH_KEY = "strix_viewer_sidebar_width";
|
||||
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
|
||||
|
||||
interface SidebarProps {
|
||||
view: View;
|
||||
onSelectView: (view: View) => void;
|
||||
issuesCount: number;
|
||||
agentCount: number;
|
||||
runCount: number;
|
||||
finished: boolean;
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
onOpenEmail: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
function readInt(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const n = raw ? parseInt(raw, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
view,
|
||||
onSelectView,
|
||||
issuesCount,
|
||||
agentCount,
|
||||
runCount,
|
||||
finished,
|
||||
verified,
|
||||
email,
|
||||
onOpenEmail,
|
||||
onOpenHistory,
|
||||
onForget,
|
||||
}: SidebarProps) {
|
||||
const [width, setWidth] = useState(() => {
|
||||
const w = readInt(WIDTH_KEY, DEFAULT_WIDTH);
|
||||
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w));
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [upgradeFeature, setUpgradeFeature] = useState<string | null>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Open the upgrade dialog for a platform feature, recording which feature
|
||||
// drove the open (the dialog's own CTAs track the deeper conversion).
|
||||
const openUpgrade = (slug: string, description: string) => {
|
||||
trackCta(slug, "sidebar");
|
||||
setUpgradeFeature(description);
|
||||
};
|
||||
|
||||
const persistWidth = useCallback((w: number) => {
|
||||
setWidth(w);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(w));
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistCollapsed = useCallback((c: boolean) => {
|
||||
setCollapsed(c);
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
|
||||
} catch {
|
||||
/* best-effort persistence */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
persistCollapsed(false);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
}, [persistCollapsed, persistWidth]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
// Global drag handlers for the resize handle. Dragging below the collapse
|
||||
// threshold hides the rail entirely.
|
||||
useEffect(() => {
|
||||
if (!isResizing || collapsed) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newWidth = e.clientX;
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
setWidth(newWidth);
|
||||
} else if (newWidth > MAX_WIDTH) {
|
||||
setWidth(MAX_WIDTH);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
const finalWidth = e.clientX;
|
||||
if (finalWidth < COLLAPSE_THRESHOLD) {
|
||||
persistCollapsed(true);
|
||||
persistWidth(DEFAULT_WIDTH);
|
||||
} else {
|
||||
persistWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, finalWidth)));
|
||||
}
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing, collapsed, persistCollapsed, persistWidth]);
|
||||
|
||||
// Close the user menu when clicking outside it.
|
||||
useEffect(() => {
|
||||
if (!showUserMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [showUserMenu]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left-edge pull zone: click to bring the rail back when collapsed. */}
|
||||
{collapsed && (
|
||||
<div
|
||||
className="fixed left-0 top-0 z-40 hidden h-full w-4 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.08)] lg:block"
|
||||
onClick={expandSidebar}
|
||||
title="Expand sidebar"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 z-20 hidden h-screen flex-shrink-0 flex-col overflow-hidden border-r border-[rgba(255,255,255,0.08)] bg-black lg:flex",
|
||||
!isResizing && "transition-[width] duration-200 ease-out"
|
||||
)}
|
||||
style={{ width: collapsed ? 0 : width }}
|
||||
>
|
||||
{/* Header — account-switcher stand-in (links out to Strix Cloud). */}
|
||||
<header className="relative flex flex-col gap-1 pt-1 min-w-[160px]">
|
||||
<div className="flex flex-row py-1 px-2">
|
||||
<div className="flex h-10 w-full flex-row items-center">
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-1 flex-row items-center gap-2 rounded-md py-2 pl-2.5 pr-1 min-w-0 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
title="Open Strix Cloud"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex flex-1 flex-row items-center gap-2 min-w-0">
|
||||
<span className="truncate min-w-0 text-[14px] font-medium text-[#ededed]">Strix</span>
|
||||
<span className="flex h-5 flex-shrink-0 items-center rounded px-2 text-[11px] font-medium text-[#888] bg-[rgba(255,255,255,0.08)]">
|
||||
Local
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl("https://app.strix.ai", "logo")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("logo", "sidebar")}
|
||||
className="flex flex-none items-center rounded-md px-1.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
aria-label="Open Strix Cloud"
|
||||
>
|
||||
<ChevronsUpDown className="h-4 w-4 text-[#666]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="relative min-w-[160px] flex-1 overflow-y-auto overflow-x-clip scrollbar-thin pb-10 pt-2">
|
||||
<div className="relative flex flex-col gap-px px-2">
|
||||
<NavItem
|
||||
icon={<ProjectsIcon />}
|
||||
label="Pentest Overview"
|
||||
active={view === "overview"}
|
||||
onClick={() => onSelectView("overview")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
label="Issues"
|
||||
count={issuesCount > 0 ? issuesCount : undefined}
|
||||
active={view === "issues"}
|
||||
onClick={() => onSelectView("issues")}
|
||||
/>
|
||||
{agentCount > 0 && (
|
||||
<NavItem
|
||||
icon={<Bot className="h-4 w-4" />}
|
||||
label="Agents"
|
||||
count={agentCount}
|
||||
active={view === "agents"}
|
||||
onClick={() => onSelectView("agents")}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<History className="h-4 w-4" />}
|
||||
label="Past runs"
|
||||
count={runCount > 0 ? runCount : undefined}
|
||||
active={view === "history"}
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
{finished && (
|
||||
<NavItem
|
||||
icon={<Mail className="h-4 w-4" />}
|
||||
label="Export report"
|
||||
active={view === "email"}
|
||||
onClick={onOpenEmail}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={<IoChatbubblesOutline className="h-4 w-4" />}
|
||||
label="Feedback & support"
|
||||
active={view === "feedback"}
|
||||
onClick={() => onSelectView("feedback")}
|
||||
/>
|
||||
|
||||
<hr className="mx-0 my-1 h-px w-full border-0 bg-[rgba(255,255,255,0.08)]" />
|
||||
|
||||
<NavItem
|
||||
icon={<LuGitPullRequestArrow className="h-4 w-4" />}
|
||||
label="PR Security Reviews"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"pr_reviews",
|
||||
"Strix reviews every pull request and flags exploitable changes before they merge."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<VscExtensions className="h-4 w-4" />}
|
||||
label="Integrations"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"integrations",
|
||||
"Sync findings to Jira, Linear, and Slack so fixes happen where your team already works."
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Members"
|
||||
active={false}
|
||||
onClick={() =>
|
||||
openUpgrade(
|
||||
"members",
|
||||
"Invite your team, set roles, and share findings and run history across your org."
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User footer — verified-email footer. */}
|
||||
<section className="flex min-w-[160px] flex-col gap-0.5" ref={userMenuRef}>
|
||||
<div className="relative p-2">
|
||||
{verified && email ? (
|
||||
<button
|
||||
onClick={() => setShowUserMenu((v) => !v)}
|
||||
className="relative flex w-full cursor-pointer items-center gap-2 rounded-md bg-transparent px-2.5 py-2 transition-colors hover:bg-[rgba(255,255,255,0.06)]"
|
||||
>
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">
|
||||
{email[0]?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">{email}</span>
|
||||
<span className="truncate text-[11px] text-[#555]">Linked to this machine</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-md px-2.5 py-2">
|
||||
<span
|
||||
className="flex flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-cyan-500"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<span className="text-[9px] font-semibold text-white">S</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col text-left">
|
||||
<span className="truncate text-[13px] font-medium text-[#ededed]">Local viewer</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUserMenu && verified && email && (
|
||||
<div className="absolute bottom-full left-2 right-2 z-50 mb-1 overflow-hidden rounded-lg border border-[#333] bg-black shadow-xl">
|
||||
<div className="border-b border-[#333] px-3 py-2">
|
||||
<p className="truncate text-[13px] font-medium text-white">Linked email</p>
|
||||
<p className="truncate text-[11px] text-[#666]">{email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
onForget();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] text-[#888] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Forget this email
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="group absolute right-0 top-0 z-30 h-full w-1 cursor-col-resize"
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-px bg-[rgba(255,255,255,0.08)] transition-all duration-100",
|
||||
isResizing ? "w-0.5 bg-[rgba(255,255,255,0.3)]" : "group-hover:bg-[rgba(255,255,255,0.2)]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay during resize to prevent text selection. */}
|
||||
{isResizing && <div className="fixed inset-0 z-10 cursor-col-resize" />}
|
||||
|
||||
<UpgradeModal
|
||||
open={upgradeFeature !== null}
|
||||
description={upgradeFeature ?? ""}
|
||||
source="sidebar"
|
||||
onClose={() => setUpgradeFeature(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
function NavItem({ icon, label, active, onClick, count }: NavItemProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group flex h-9 w-full origin-left flex-row items-center rounded-md transition-colors",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.12)] text-white"
|
||||
: "text-[#888] hover:bg-[rgba(255,255,255,0.06)] hover:text-[#ededed]"
|
||||
)}
|
||||
>
|
||||
<div className="grid flex-none place-content-center" style={{ width: 36, height: 36 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-[14px] font-medium">{label}</span>
|
||||
{count != null && (
|
||||
<span className="mr-2 flex-none rounded-full border border-white/10 px-2 py-0.5 text-[10px] tabular-nums leading-none text-[#777]">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Overview icon: a dashboard grid glyph (16x16 viewBox).
|
||||
function ProjectsIcon() {
|
||||
return (
|
||||
<svg style={{ width: 16, height: 16, color: "currentcolor" }} viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.5 5.5V2.5H5.5V5.5H2.5ZM1 2C1 1.44772 1.44772 1 2 1H6C6.55228 1 7 1.44772 7 2V6C7 6.55228 6.55228 7 6 7H2C1.44772 7 1 6.55228 1 6V2ZM2.5 13.5V10.5H5.5V13.5H2.5ZM1 10C1 9.44772 1.44772 9 2 9H6C6.55228 9 7 9.44772 7 10V14C7 14.5523 6.55228 15 6 15H2C1.44772 15 1 14.5523 1 14V10ZM10.5 2.5V5.5H13.5V2.5H10.5ZM10 1C9.44772 1 9 1.44772 9 2V6C9 6.55228 9.44772 7 10 7H14C14.5523 7 15 6.55228 15 6V2C15 1.44772 14.5523 1 14 1H10ZM10.5 13.5V10.5H13.5V13.5H10.5ZM9 10C9 9.44772 9.44772 9 10 9H14C14.5523 9 15 9.44772 15 10V14C15 14.5523 14.5523 15 14 15H10C9.44772 15 9 14.5523 9 14V10Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ShieldCheck, X } from "lucide-react";
|
||||
|
||||
const DISMISS_KEY = "strix_viewer_trust_dismissed";
|
||||
|
||||
/**
|
||||
* One-time privacy notice, shown as a toast pinned over the sidebar. Dismissing
|
||||
* it persists to localStorage so it never returns on reload or view changes.
|
||||
*/
|
||||
export function TrustToast({ message }: { message: string }) {
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, "1");
|
||||
} catch {
|
||||
/* non-fatal: worst case the toast shows again next session */
|
||||
}
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl"
|
||||
style={{ border: "1px solid #2a2a2a" }}
|
||||
role="status"
|
||||
>
|
||||
<div className="flex gap-2.5">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
|
||||
<p className="text-xs leading-relaxed text-[#aaa]">{message}</p>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss"
|
||||
className="-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrustToast;
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Shield,
|
||||
Zap,
|
||||
CalendarClock,
|
||||
WandSparkles,
|
||||
Plug,
|
||||
} from "lucide-react";
|
||||
import { SIGNUP_URL, PRICING_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
|
||||
/**
|
||||
* Dialog shown when a platform feature is clicked in the sidebar: a short
|
||||
* description of the feature plus what Strix Cloud includes. The local viewer
|
||||
* has no billing, so both CTAs link out to the public sign-up / pricing pages.
|
||||
*/
|
||||
|
||||
const CLOUD_HIGHLIGHTS: { icon: React.ElementType; label: string }[] = [
|
||||
{ icon: GitPullRequest, label: "PR security reviews" },
|
||||
{ icon: Shield, label: "Attack surface monitoring" },
|
||||
{ icon: Zap, label: "Real-time threat intelligence" },
|
||||
{ icon: CalendarClock, label: "Scheduled pentesting" },
|
||||
{ icon: WandSparkles, label: "One-click autofix" },
|
||||
{ icon: Plug, label: "Jira, Linear & Slack integrations" },
|
||||
];
|
||||
|
||||
export function UpgradeModal({
|
||||
open,
|
||||
onClose,
|
||||
description,
|
||||
source = "sidebar",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** A short sentence describing what the clicked feature does. */
|
||||
description: string;
|
||||
source?: string;
|
||||
}) {
|
||||
// Keep the dialog mounted through its exit animation: `render` controls
|
||||
// presence in the DOM and `state` ("open"/"closed") drives the keyframe. On
|
||||
// close we flip to "closed", let the 200ms animation play, then unmount --
|
||||
// the same lifecycle Radix gives shadcn's Dialog.
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Upgrade your plan"
|
||||
>
|
||||
<div
|
||||
data-state={state}
|
||||
className="dialog-panel relative w-full max-w-md rounded-2xl border border-[#222] bg-black p-6 shadow-lg sm:rounded-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="absolute right-4 top-4 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg text-white">Available in Strix Cloud</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-base leading-relaxed text-[#e5e5e5]">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="rounded-xl border border-[#333] bg-[#0a0a0a] p-4 sm:rounded-lg">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Strix Cloud also includes</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-[#888]">
|
||||
{CLOUD_HIGHLIGHTS.map((f) => (
|
||||
<li key={f.label} className="flex items-center gap-2">
|
||||
<f.icon className="h-3.5 w-3.5 text-[#555]" />
|
||||
{f.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href={ctaUrl(SIGNUP_URL, "upgrade_try_free")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_try_free", source)}
|
||||
className="flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-white px-4 text-sm font-semibold text-black transition-colors hover:bg-neutral-200"
|
||||
>
|
||||
Open Strix Cloud
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<a
|
||||
href={ctaUrl(PRICING_URL, "upgrade_view_plans")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta("upgrade_view_plans", source)}
|
||||
className="flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-[#333] px-4 text-sm font-medium text-[#888] transition-colors hover:border-[#555] hover:text-white"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpgradeModal;
|
||||
@@ -1,167 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { AgentTranscript } from "./AgentTranscript";
|
||||
import { ScanPromptComposer } from "./ScanPromptComposer";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/** Status -> the small leading dot color, matching the graph node styling. */
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
completed: "bg-emerald-400",
|
||||
running: "bg-blue-400",
|
||||
waiting: "bg-yellow-400",
|
||||
stopped: "bg-[#888]",
|
||||
crashed: "bg-red-400",
|
||||
failed: "bg-red-400",
|
||||
};
|
||||
|
||||
/** Consider the user "at the bottom" within this many px. */
|
||||
const NEAR_BOTTOM_PX = 80;
|
||||
|
||||
/**
|
||||
* Overlay modal showing a single agent's full transcript. A centered
|
||||
* ``max-w-6xl`` / ``60vh`` panel that animates in and out via the shared
|
||||
* ``agent-modal`` data-state keyframes (fade), with a pinned header
|
||||
* (status dot + agent name),
|
||||
* the transcript scrolling beneath it, and a footer. Auto-scrolls to follow new
|
||||
* activity while the user is near the bottom. Closes on backdrop click, the X
|
||||
* button, or Escape.
|
||||
*
|
||||
* Driven by an ``open`` prop (rather than conditional mounting) so the exit
|
||||
* animation can play before unmount; the last agent is retained through the
|
||||
* close so content doesn't blank out mid-animation.
|
||||
*/
|
||||
export function AgentDetailModal({
|
||||
open,
|
||||
agent,
|
||||
events,
|
||||
steerable,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
agent: TranscriptAgent | null;
|
||||
events: TranscriptEvent[];
|
||||
steerable: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const nearBottom = useRef(false);
|
||||
|
||||
// Keep the modal mounted through its exit animation (see UpgradeModal).
|
||||
const [render, setRender] = useState(open);
|
||||
const [state, setState] = useState<"open" | "closed">(open ? "open" : "closed");
|
||||
// Defer the (heavy) transcript one frame so the shell + fade paint instantly
|
||||
// instead of waiting on the full event list to render.
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
|
||||
// Retain the last non-null agent so the panel keeps rendering its content
|
||||
// during the close animation, after the parent has cleared the selection.
|
||||
const lastAgentRef = useRef<TranscriptAgent | null>(agent);
|
||||
useEffect(() => {
|
||||
if (agent) lastAgentRef.current = agent;
|
||||
}, [agent]);
|
||||
const shownAgent = agent ?? lastAgentRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRender(true);
|
||||
setState("open");
|
||||
return;
|
||||
}
|
||||
setState("closed");
|
||||
const t = setTimeout(() => setRender(false), 140);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
// Mount the transcript a frame after the shell is on screen.
|
||||
useEffect(() => {
|
||||
if (!render) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = requestAnimationFrame(() => setContentReady(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [render]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
nearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
|
||||
}, []);
|
||||
|
||||
// Follow new activity when the user is near the bottom (live trailing).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !nearBottom.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!render) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [render, onClose]);
|
||||
|
||||
if (!render || !shownAgent) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={state}
|
||||
className="agent-modal fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 sm:p-8"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Agent ${shownAgent.name}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[60vh] w-[calc(100vw-4rem)] max-w-6xl flex-col overflow-hidden rounded-xl border border-[#222] bg-[#0a0a0a] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#222] px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 flex-shrink-0 rounded-full ${STATUS_DOT[shownAgent.status] ?? "bg-[#888]"}`}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold text-white">{shownAgent.name}</span>
|
||||
<span className="flex-shrink-0 font-mono text-xs text-[#555]">{shownAgent.id}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex-shrink-0 rounded-md p-1 text-[#888] transition-colors hover:bg-[#1a1a1a] hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-5">
|
||||
{contentReady && (
|
||||
<AgentTranscript agent={shownAgent} events={events} showHeader={false} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{steerable && (
|
||||
<div className="border-t border-[#222] px-5 py-3">
|
||||
<ScanPromptComposer
|
||||
agents={[shownAgent]}
|
||||
fixedAgentId={shownAgent.id}
|
||||
className="mt-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentDetailModal;
|
||||
@@ -1,254 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import AgentNodeComponent from "./AgentNode";
|
||||
import GraphSkeleton from "./GraphSkeleton";
|
||||
import type { AgentNode } from "@/types/events";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const NODE_WIDTH = 260;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
const nodeTypes = { agentNode: AgentNodeComponent };
|
||||
|
||||
function getLayoutedElements(
|
||||
agents: Map<string, AgentNode>,
|
||||
selectedAgentId: string | null
|
||||
) {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
for (const [id, agent] of agents) {
|
||||
g.setNode(id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
nodes.push({
|
||||
id,
|
||||
type: "agentNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...agent, isSelected: id === selectedAgentId },
|
||||
});
|
||||
|
||||
if (agent.parentId && agents.has(agent.parentId)) {
|
||||
const edgeId = `${agent.parentId}->${id}`;
|
||||
g.setEdge(agent.parentId, id);
|
||||
edges.push({
|
||||
id: edgeId,
|
||||
source: agent.parentId,
|
||||
target: id,
|
||||
style: { stroke: "#2a2a2a", strokeWidth: 1.5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
for (const node of nodes) {
|
||||
const pos = g.node(node.id);
|
||||
if (pos) {
|
||||
node.position = {
|
||||
x: pos.x - NODE_WIDTH / 2,
|
||||
y: pos.y - NODE_HEIGHT / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
const ZOOM_DURATION = 300;
|
||||
|
||||
|
||||
/** Centers viewport on the root node (no parentId) at a fixed zoom — only once on first load */
|
||||
function CenterOnRoot({ nodes }: { nodes: Node[] }) {
|
||||
const { setCenter } = useReactFlow();
|
||||
const hasCentered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (nodes.length > 0 && !hasCentered.current) {
|
||||
const root = nodes.find((n) => !(n.data as Record<string, unknown>).parentId);
|
||||
const target = root ?? nodes[0];
|
||||
hasCentered.current = true;
|
||||
const cx = target.position.x + NODE_WIDTH / 2;
|
||||
const cy = target.position.y + NODE_HEIGHT / 2;
|
||||
setTimeout(() => setCenter(cx, cy, { zoom: 0.85, duration: 400 }), 60);
|
||||
}
|
||||
}, [nodes, setCenter]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SmoothControls() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
return (
|
||||
<Controls
|
||||
position="bottom-right"
|
||||
showZoom={false}
|
||||
showFitView={false}
|
||||
showInteractive={false}
|
||||
className="!bg-transparent !border-none !shadow-none"
|
||||
>
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border border-[#222]">
|
||||
<button onClick={() => zoomIn({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Zoom in">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<button onClick={() => zoomOut({ duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] border-y border-[#222] transition-colors" title="Zoom out">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M5 12h14" /></svg>
|
||||
</button>
|
||||
<button onClick={() => fitView({ padding: 0.3, duration: ZOOM_DURATION })} className="flex items-center justify-center w-7 h-7 bg-[#111] text-white hover:bg-[#2a2a2a] transition-colors" title="Fit view">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="w-3.5 h-3.5"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</Controls>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentGraphProps {
|
||||
agents: Map<string, AgentNode>;
|
||||
selectedAgentId: string | null;
|
||||
onSelectAgent: (id: string | null) => void;
|
||||
eventsLoaded?: boolean;
|
||||
eventsEmpty?: boolean;
|
||||
scanCompleted?: boolean;
|
||||
}
|
||||
|
||||
export default function AgentGraph({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
eventsLoaded,
|
||||
eventsEmpty,
|
||||
scanCompleted,
|
||||
}: AgentGraphProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.size === 0) return;
|
||||
const { nodes: ln, edges: le } = getLayoutedElements(agents, selectedAgentId);
|
||||
setNodes(ln);
|
||||
setEdges(le);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agents.size, setNodes, setEdges]);
|
||||
|
||||
// Sync agent data (status, name, etc.) into existing nodes without re-layout
|
||||
useEffect(() => {
|
||||
if (agents.size === 0) return;
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => {
|
||||
const agent = agents.get(n.id);
|
||||
if (!agent) return n;
|
||||
return { ...n, data: { ...agent, isSelected: n.id === selectedAgentId } };
|
||||
})
|
||||
);
|
||||
}, [agents, selectedAgentId, setNodes]);
|
||||
|
||||
const nodeClickedRef = useRef(false);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: Node) => {
|
||||
nodeClickedRef.current = true;
|
||||
onSelectAgent(node.id);
|
||||
},
|
||||
[onSelectAgent]
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
if (nodeClickedRef.current) {
|
||||
nodeClickedRef.current = false;
|
||||
return;
|
||||
}
|
||||
onSelectAgent(null);
|
||||
}, [onSelectAgent]);
|
||||
|
||||
// Convex responded, zero events — show empty state (not skeleton)
|
||||
if (agents.size === 0 && eventsLoaded && eventsEmpty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="w-10 h-10 mb-3 rounded-full bg-[#111] flex items-center justify-center">
|
||||
{scanCompleted ? (
|
||||
<svg className="w-5 h-5 text-[#444]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[#555]">
|
||||
{scanCompleted
|
||||
? "Agent trace data is not available for this pentest"
|
||||
: "Waiting for agent data\u2026"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showGraph = agents.size > 0;
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
{/* Skeleton overlay — fades out when graph is ready */}
|
||||
<div
|
||||
className={`absolute inset-0 z-10 transition-opacity duration-500 ${
|
||||
showGraph ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<GraphSkeleton />
|
||||
</div>
|
||||
|
||||
{/* Graph — fades in */}
|
||||
<div
|
||||
className={`h-full transition-opacity duration-500 ${
|
||||
showGraph ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesConnectable={false}
|
||||
edgesFocusable={false}
|
||||
edgesReconnectable={false}
|
||||
minZoom={0.15}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="bg-black"
|
||||
>
|
||||
<Background color="#111" gap={20} />
|
||||
<CenterOnRoot nodes={nodes} />
|
||||
<SmoothControls />
|
||||
<MiniMap
|
||||
position="bottom-left"
|
||||
nodeColor={(n) => {
|
||||
const status = (n.data as Record<string, unknown>)?.status as string;
|
||||
if (status === "running") return "#3b82f6";
|
||||
if (status === "completed") return "#10b981";
|
||||
if (status === "failed" || status === "error") return "#ef4444";
|
||||
return "#555";
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.8)"
|
||||
style={{ width: 80, height: 50 }}
|
||||
className="!bg-[#0a0a0a] !border-[#222]"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import type { AgentNode as AgentNodeData } from "@/types/events";
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
running: "bg-blue-500",
|
||||
completed: "bg-emerald-500",
|
||||
failed: "bg-red-500",
|
||||
error: "bg-red-500",
|
||||
};
|
||||
|
||||
function AgentNodeComponent({ data, selected }: NodeProps) {
|
||||
const agent = data as unknown as AgentNodeData & { isSelected: boolean };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[260px] rounded-lg border px-4 py-3 transition-colors ${
|
||||
agent.isSelected || selected
|
||||
? "border-white/30 bg-[#0a0a0a]"
|
||||
: "border-[#222] bg-black hover:border-[#333]"
|
||||
}`}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.parentId ? "!bg-[#444]" : "!bg-transparent"}`} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${STATUS_STYLES[agent.status] ?? "bg-gray-500"} ${
|
||||
agent.status === "running" ? "animate-ping" : ""
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${STATUS_STYLES[agent.status] ?? "bg-gray-500"}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white leading-snug line-clamp-3">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} className={`!w-1.5 !h-1.5 !border-0 ${agent.children && agent.children.length > 0 ? "!bg-[#444]" : "!bg-transparent"}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AgentNodeComponent);
|
||||
@@ -1,302 +0,0 @@
|
||||
import { Component, useMemo, type ReactNode } from "react";
|
||||
import { Brain, Bot } from "lucide-react";
|
||||
import { getToolRenderer, getToolIcon } from "./tool-renderers";
|
||||
import ChatBubble from "./tool-renderers/ChatBubble";
|
||||
import type { ToolRendererProps, AgentNode as GraphAgentNode } from "@/types/events";
|
||||
import type { TranscriptAgent, TranscriptEvent } from "@/data/serverSource";
|
||||
|
||||
/* ---------- Error boundary so one bad event never blanks the transcript ---------- */
|
||||
class RendererErrorBoundary extends Component<
|
||||
{ toolName: string; children: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { toolName: string; children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<span className="text-[#555] font-semibold text-sm">
|
||||
{this.props.toolName.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function SafeToolRenderer(props: ToolRendererProps) {
|
||||
const Renderer = getToolRenderer(props.toolName);
|
||||
return (
|
||||
<RendererErrorBoundary toolName={props.toolName}>
|
||||
<Renderer {...props} />
|
||||
</RendererErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Value coercion ----------
|
||||
* args/result arrive as either a JSON object or a Python-repr string
|
||||
* ("{'thought': '...'}"). Try JSON, then a naive python->json pass, then wrap
|
||||
* the raw string so the fallback renderer can display it. Never throws. */
|
||||
function coerce(value: unknown): unknown {
|
||||
if (value == null || typeof value !== "string") return value;
|
||||
const t = value.trim();
|
||||
if (!t) return value;
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
try {
|
||||
const jsonish = t
|
||||
.replace(/\bNone\b/g, "null")
|
||||
.replace(/\bTrue\b/g, "true")
|
||||
.replace(/\bFalse\b/g, "false")
|
||||
.replace(/'/g, '"');
|
||||
return JSON.parse(jsonish);
|
||||
} catch {
|
||||
return { __raw: value };
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
const c = coerce(value);
|
||||
if (c && typeof c === "object" && !Array.isArray(c)) return c as Record<string, unknown>;
|
||||
if (c == null) return {};
|
||||
return { __raw: typeof c === "string" ? c : JSON.stringify(c) };
|
||||
}
|
||||
|
||||
/** Numeric suffix of an event id ("tool_37" -> 37) for stable ordering. */
|
||||
function eventSeq(id: string): number {
|
||||
const m = /(\d+)$/.exec(id);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
}
|
||||
|
||||
/** A chat event whose author is the human/user (vs an assistant "thinking"). */
|
||||
function isUserChat(event: TranscriptEvent): boolean {
|
||||
const role = event.data?.role;
|
||||
return event.type === "chat" && (role === "user" || role === "human");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inter-agent message deliveries land in the recipient's session as user-role
|
||||
* items prefixed with a header (see the engine's message formatter). They are
|
||||
* already represented via the sending agent's tool renderer, so we never render
|
||||
* them as chat bubbles here.
|
||||
*/
|
||||
function isInterAgentDelivery(event: TranscriptEvent): boolean {
|
||||
return isUserChat(event) && String(event.data?.content ?? "").startsWith("[Message from ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconstructed SDK session records every incoming user-role item for an
|
||||
* agent: its initial input (the root's assembled brief, or a subagent's spawn /
|
||||
* inherited-context prompt), inter-agent deliveries, AND genuine human steering
|
||||
* messages sent live from the viewer or TUI. We only want the last group. Given
|
||||
* an agent's events in order, hide the first user message (its initial input)
|
||||
* and every inter-agent delivery; keep the rest, which are the human's live
|
||||
* instructions, rendered as "User" bubbles.
|
||||
*/
|
||||
function hiddenUserEventIds(agentEventsInOrder: TranscriptEvent[]): Set<string> {
|
||||
const hidden = new Set<string>();
|
||||
let sawInitialInput = false;
|
||||
for (const e of agentEventsInOrder) {
|
||||
if (!isUserChat(e)) continue;
|
||||
if (isInterAgentDelivery(e)) {
|
||||
hidden.add(e.id);
|
||||
continue;
|
||||
}
|
||||
if (!sawInitialInput) {
|
||||
sawInitialInput = true;
|
||||
hidden.add(e.id);
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
completed: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10",
|
||||
running: "text-blue-400 border-blue-500/30 bg-blue-500/10",
|
||||
waiting: "text-yellow-400 border-yellow-500/30 bg-yellow-500/10",
|
||||
stopped: "text-[#aaa] border-[#333] bg-[#1a1a1a]",
|
||||
crashed: "text-red-400 border-red-500/30 bg-red-500/10",
|
||||
failed: "text-red-400 border-red-500/30 bg-red-500/10",
|
||||
};
|
||||
|
||||
/** Map our engine agent statuses onto the graph node's status union. */
|
||||
function graphStatus(status: string): GraphAgentNode["status"] {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "running") return "running";
|
||||
if (status === "failed" || status === "crashed") return "failed";
|
||||
// waiting / stopped / unknown → keep the raw string; AgentNode/MiniMap fall
|
||||
// back to a neutral gray for anything they don't explicitly style.
|
||||
return status as GraphAgentNode["status"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt transcript agents + events into the Map<id, AgentNode> that the live
|
||||
* AgentGraph renders: children from parent_id, tool/message counts by scanning
|
||||
* events, and a task pulled from the spawning create_agent call where present.
|
||||
*/
|
||||
export function buildGraphAgents(
|
||||
agents: TranscriptAgent[],
|
||||
events: TranscriptEvent[]
|
||||
): Map<string, GraphAgentNode> {
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const a of agents) {
|
||||
if (a.parent_id) {
|
||||
const arr = childrenOf.get(a.parent_id) ?? [];
|
||||
arr.push(a.id);
|
||||
childrenOf.set(a.parent_id, arr);
|
||||
}
|
||||
}
|
||||
|
||||
const toolCount = new Map<string, number>();
|
||||
const messageCount = new Map<string, number>();
|
||||
// A create_agent call names the child but not its id, so map spawned tasks by
|
||||
// agent NAME (best-effort — used only for the graph node subtitle).
|
||||
const taskByName = new Map<string, string>();
|
||||
for (const e of events) {
|
||||
if (e.type === "tool") {
|
||||
toolCount.set(e.agent_id, (toolCount.get(e.agent_id) ?? 0) + 1);
|
||||
if (e.data?.tool_name === "create_agent") {
|
||||
const args = asRecord(e.data.args);
|
||||
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
|
||||
const task = (args.task as string) ?? "";
|
||||
if (name && task) taskByName.set(name, task);
|
||||
}
|
||||
} else if (!isUserChat(e)) {
|
||||
// Count only assistant messages for the graph node subtitle.
|
||||
messageCount.set(e.agent_id, (messageCount.get(e.agent_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map<string, GraphAgentNode>();
|
||||
for (const a of agents) {
|
||||
map.set(a.id, {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
task: taskByName.get(a.name) ?? "",
|
||||
status: graphStatus(a.status),
|
||||
parentId: a.parent_id,
|
||||
children: childrenOf.get(a.id) ?? [],
|
||||
createdAt: a.created_at,
|
||||
toolCount: toolCount.get(a.id) ?? 0,
|
||||
messageCount: messageCount.get(a.id) ?? 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/* ---------- Per-agent transcript ---------- */
|
||||
export function AgentTranscript({
|
||||
agent,
|
||||
events,
|
||||
showHeader = true,
|
||||
}: {
|
||||
agent: TranscriptAgent;
|
||||
events: TranscriptEvent[];
|
||||
showHeader?: boolean;
|
||||
}) {
|
||||
const mine = useMemo(() => {
|
||||
const ordered = events
|
||||
.filter((e) => e.agent_id === agent.id)
|
||||
.sort((a, b) => eventSeq(a.id) - eventSeq(b.id));
|
||||
const hidden = hiddenUserEventIds(ordered);
|
||||
return ordered.filter((e) => !hidden.has(e.id));
|
||||
}, [events, agent.id]);
|
||||
|
||||
const toolCount = mine.filter((e) => e.type === "tool").length;
|
||||
const msgCount = mine.length - toolCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showHeader && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="text-base font-semibold text-white truncate">{agent.name}</span>
|
||||
<span
|
||||
className={`flex-shrink-0 text-xs font-medium capitalize px-2 py-0.5 rounded-full border ${
|
||||
STATUS_STYLE[agent.status] ?? "text-[#aaa] border-[#333] bg-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{agent.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[#555]">{agent.id}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[#666] mb-4">
|
||||
{msgCount} message{msgCount === 1 ? "" : "s"} · {toolCount} tool call
|
||||
{toolCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mine.length === 0 ? (
|
||||
<p className="text-sm text-[#666]">No recorded activity for this agent.</p>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{mine.map((event, i) => {
|
||||
const isLast = i === mine.length - 1;
|
||||
const isTool = event.type === "tool";
|
||||
const toolName = isTool ? String(event.data?.tool_name ?? "tool") : "";
|
||||
const role = !isTool ? String(event.data?.role ?? "assistant") : "";
|
||||
|
||||
let Icon;
|
||||
let iconColor: string;
|
||||
if (isTool) {
|
||||
const meta = getToolIcon(toolName);
|
||||
Icon = meta.icon;
|
||||
iconColor = meta.color;
|
||||
} else {
|
||||
const isUser = role === "user" || role === "human";
|
||||
Icon = isUser ? Bot : Brain;
|
||||
iconColor = isUser ? "text-blue-400" : "text-purple-400";
|
||||
}
|
||||
|
||||
const status = isTool ? String(event.data?.status ?? "completed") : "completed";
|
||||
|
||||
return (
|
||||
<div key={event.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div
|
||||
className={`w-[30px] h-[30px] rounded-full bg-black border flex items-center justify-center shrink-0 ${
|
||||
isTool && status === "running"
|
||||
? "border-blue-500/40 animate-pulse"
|
||||
: isTool && status === "failed"
|
||||
? "border-red-500/30"
|
||||
: "border-[#222]"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-[#1a1a1a] mt-1" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-[5px] pb-6">
|
||||
{isTool ? (
|
||||
<SafeToolRenderer
|
||||
toolName={toolName}
|
||||
args={asRecord(event.data?.args)}
|
||||
result={coerce(event.data?.result) ?? null}
|
||||
status={
|
||||
status as ToolRendererProps["status"]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ChatBubble
|
||||
role={role}
|
||||
content={String(event.data?.content ?? "")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
function SkeletonNode({ w = 24 }: { w?: number }) {
|
||||
return (
|
||||
<div className="w-[180px] h-[72px] rounded-lg border border-[#222] bg-[#0a0a0a] px-3 py-2 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-[#2a2a2a]" />
|
||||
<div className="h-3 rounded bg-[#252525]" style={{ width: `${w * 4}px` }} />
|
||||
</div>
|
||||
<div className="h-2 w-28 rounded bg-[#1e1e1e] mb-1.5" />
|
||||
<div className="flex gap-3">
|
||||
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
|
||||
<div className="h-2 w-8 rounded bg-[#1e1e1e]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VLine() {
|
||||
return <div className="w-px h-6 bg-[#2a2a2a]" />;
|
||||
}
|
||||
|
||||
function HBranch({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="relative flex justify-center">
|
||||
<div className="absolute top-0 h-px bg-[#2a2a2a]" style={{ width: `${(count - 1) * 220}px` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphSkeleton() {
|
||||
return (
|
||||
<div className="h-full bg-black overflow-hidden">
|
||||
<div className="flex flex-col items-center pt-10 animate-pulse">
|
||||
<SkeletonNode w={20} />
|
||||
<VLine />
|
||||
<HBranch count={3} />
|
||||
<div className="flex gap-10">
|
||||
{[18, 22, 16].map((w, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={w} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-10 w-full justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<HBranch count={2} />
|
||||
<div className="flex gap-10">
|
||||
{[14, 20].map((w, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={w} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<VLine />
|
||||
<SkeletonNode w={18} />
|
||||
<VLine />
|
||||
<SkeletonNode w={12} />
|
||||
</div>
|
||||
<div className="w-[180px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowUp, ChevronDown, ChevronUp, Loader2, Sparkles } from "lucide-react";
|
||||
import { steerAgent, type TranscriptAgent } from "@/data/serverSource";
|
||||
import { track } from "@/lib/cta";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ROOT_TARGET_VALUE = "__root__";
|
||||
|
||||
interface ScanPromptComposerProps {
|
||||
/** All agents in the run; used to resolve the root and running children. */
|
||||
agents: TranscriptAgent[];
|
||||
/**
|
||||
* Single-agent (modal) mode: pins the composer to one agent and shows a
|
||||
* static "Target: <name>" pill instead of the dropdown. Omit for the
|
||||
* multi-agent graph variant.
|
||||
*/
|
||||
fixedAgentId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Faithful port of the pro app's ScanPromptComposer for the local viewer.
|
||||
* Collapsed by default into a "Guide the agent" pill; expands into a card with
|
||||
* an auto-resizing textarea and a target control. The viewer's steering is
|
||||
* immediate (no Enterprise lock, no bridge-connecting state), so this is only
|
||||
* rendered by callers when steering is available. Sends via steerAgent, which
|
||||
* requires a concrete agent id, so "Root agent" resolves to the root agent's id.
|
||||
*/
|
||||
export function ScanPromptComposer({
|
||||
agents,
|
||||
fixedAgentId,
|
||||
className,
|
||||
}: ScanPromptComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
const isModal = fixedAgentId != null;
|
||||
|
||||
// Root = the agent with no parent; fall back to the first agent.
|
||||
const rootAgent = useMemo(
|
||||
() => agents.find((a) => !a.parent_id) ?? agents[0] ?? null,
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Multi-agent dropdown options: running child agents plus Root (added in JSX).
|
||||
const targetOptions = useMemo(
|
||||
() => agents.filter((a) => a.parent_id && a.status === "running"),
|
||||
[agents]
|
||||
);
|
||||
|
||||
// Selected target for the multi-agent variant. ROOT sentinel by default.
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(ROOT_TARGET_VALUE);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// If the selected child target disappears (finished), fall back to Root.
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTarget !== ROOT_TARGET_VALUE &&
|
||||
!targetOptions.some((a) => a.id === selectedTarget)
|
||||
) {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
}
|
||||
}, [selectedTarget, targetOptions]);
|
||||
|
||||
// Resolve the concrete agent id + display name for the current target.
|
||||
const { targetId, targetName } = useMemo(() => {
|
||||
if (isModal) {
|
||||
const agent = agents.find((a) => a.id === fixedAgentId) ?? null;
|
||||
return {
|
||||
targetId: fixedAgentId ?? null,
|
||||
targetName: agent?.name ?? "this agent",
|
||||
};
|
||||
}
|
||||
if (selectedTarget === ROOT_TARGET_VALUE) {
|
||||
return {
|
||||
targetId: rootAgent?.id ?? null,
|
||||
targetName: "Root agent",
|
||||
};
|
||||
}
|
||||
const agent = agents.find((a) => a.id === selectedTarget) ?? null;
|
||||
return {
|
||||
targetId: agent?.id ?? rootAgent?.id ?? null,
|
||||
targetName: agent?.name ?? "Root agent",
|
||||
};
|
||||
}, [agents, fixedAgentId, isModal, rootAgent, selectedTarget]);
|
||||
|
||||
const empty = value.trim().length === 0;
|
||||
|
||||
// Grow the textarea with its content, capped by max-h via CSS.
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value]);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
setExpanded(true);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setFocused(false);
|
||||
setMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (sending) return;
|
||||
const message = value.trim();
|
||||
if (!message || !targetId) return;
|
||||
|
||||
setSending(true);
|
||||
setFeedback(null);
|
||||
const name = targetName;
|
||||
const res = await steerAgent(targetId, message);
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setValue("");
|
||||
setFeedback(`Sent to ${name}`);
|
||||
track("agent_steered");
|
||||
} else if (res.error === "not_delivered") {
|
||||
setFeedback("Could not reach that agent (it may have finished).");
|
||||
} else {
|
||||
setFeedback("Could not send that message. Try again.");
|
||||
}
|
||||
}, [sending, value, targetId, targetName]);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-3 rounded-2xl border border-white/[0.08] bg-[#050505] px-5 py-3 text-left transition-colors duration-300 hover:border-white/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20",
|
||||
className
|
||||
)}
|
||||
aria-expanded={false}
|
||||
aria-label="Expand live prompt composer"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-[#666]" />
|
||||
<span className="truncate text-sm font-medium text-white">Guide the agent</span>
|
||||
</div>
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-[#777]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 rounded-2xl border border-white/[0.08] bg-[#050505] overflow-hidden transition-colors duration-300",
|
||||
focused ? "border-white/[0.18]" : "hover:border-white/[0.12]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/[0.06] px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-[#666]" />
|
||||
<p className="text-sm font-medium text-white">Live prompt</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[#777]">Connected</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isModal ? (
|
||||
<div className="rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1 text-xs text-[#aaa]">
|
||||
Target: <span className="text-white">{targetName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[#aaa]">Target:</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
onBlur={() => requestAnimationFrame(() => setMenuOpen(false))}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 text-xs text-white transition-colors hover:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<span className="max-w-[140px] truncate">{targetName}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[#999]" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute right-0 z-10 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-[#333] bg-[#0a0a0a] py-1 shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
<TargetMenuItem
|
||||
label="Root agent"
|
||||
active={selectedTarget === ROOT_TARGET_VALUE}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(ROOT_TARGET_VALUE);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
{targetOptions.map((option) => (
|
||||
<TargetMenuItem
|
||||
key={option.id}
|
||||
label={option.name}
|
||||
active={selectedTarget === option.id}
|
||||
onSelect={() => {
|
||||
setSelectedTarget(option.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCollapse}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[#777] transition-colors hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
|
||||
aria-label="Collapse live prompt composer"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4 pb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Send a live prompt to the running pentest…"
|
||||
maxLength={4000}
|
||||
disabled={sending}
|
||||
className="block w-full resize-none border-0 bg-transparent p-0 text-[15px] leading-6 text-white placeholder:text-[#444] focus:outline-none disabled:opacity-60 max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-4 pb-4">
|
||||
<div className="text-xs text-[#666]">{feedback ?? "Press Enter to send."}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={sending || empty}
|
||||
className={cn(
|
||||
"inline-flex h-10 min-w-[112px] items-center justify-center gap-2 rounded-full px-4 text-sm font-medium transition-colors",
|
||||
sending || empty
|
||||
? "bg-white/[0.08] text-[#666]"
|
||||
: "bg-white text-black hover:bg-neutral-200"
|
||||
)}
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" strokeWidth={2.5} />
|
||||
)}
|
||||
<span>Send prompt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetMenuItem({
|
||||
label,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
// onMouseDown so the click lands before the trigger's onBlur closes the menu.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full truncate px-3 py-1.5 text-left text-xs transition-colors hover:bg-white/[0.06]",
|
||||
active ? "text-white" : "text-[#aaa]"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScanPromptComposer;
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps) {
|
||||
if (toolName === "create_agent") {
|
||||
const name = (args.name as string) ?? (args.agent_name as string) ?? "";
|
||||
const task = (args.task as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">spawning</span>
|
||||
{name && <span className="text-cyan-400 font-semibold text-sm">{name}</span>}
|
||||
</div>
|
||||
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "agent_finish") {
|
||||
const summary = (args.result_summary as string) ?? "";
|
||||
const success = args.success as boolean | undefined;
|
||||
const rawFindings = args.findings;
|
||||
const findings = Array.isArray(rawFindings) ? rawFindings as string[] : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className={`font-semibold text-sm ${success === false ? "text-red-400/80" : "text-emerald-400/80"}`}>
|
||||
{success === false ? "Agent failed" : "Agent completed"}
|
||||
</span>
|
||||
{summary && <div className="mt-1.5"><TruncatedText text={summary} maxLines={20} /></div>}
|
||||
{findings && findings.length > 0 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{findings.map((f, i) => (
|
||||
<div key={i} className="text-[13px] text-[#888]"><span className="text-red-400/50 mr-1">•</span>{typeof f === "string" ? f : JSON.stringify(f)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "send_message_to_agent") {
|
||||
const message = (args.message as string) ?? "";
|
||||
const agentId = (args.target_agent_id as string) ?? (args.agent_id as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">message</span>
|
||||
{agentId && <span className="text-[#888] text-[13px]">to {agentId.slice(0, 16)}</span>}
|
||||
</div>
|
||||
{message && <div className="mt-1.5"><TruncatedText text={message} maxLines={20} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "wait_for_message") {
|
||||
const reason = (args.reason as string) ?? "";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">waiting</span>
|
||||
{reason && <span className="text-[#888] text-[13px] truncate">{reason}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "stop_agent") {
|
||||
const targetAgentId = (args.target_agent_id as string) ?? "";
|
||||
const cascade = args.cascade !== false;
|
||||
const reason = (args.reason as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-red-400/80 font-semibold text-sm">stopping</span>
|
||||
{targetAgentId && <span className="text-[#888] text-[13px]">{targetAgentId.slice(0, 16)}</span>}
|
||||
{cascade && <span className="text-[#555] text-[13px] italic">+ descendants</span>}
|
||||
</div>
|
||||
{reason && <div className="mt-1.5 text-[#888] text-[13px]">{reason}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "view_agent_graph") {
|
||||
return (
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">viewing agents graph</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-cyan-400/80 font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
|
||||
);
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
const DIFF_PREVIEW_LINES = 30;
|
||||
|
||||
const BEGIN_PATCH = "*** Begin Patch";
|
||||
const END_PATCH = "*** End Patch";
|
||||
const ADD_FILE = "*** Add File: ";
|
||||
const UPDATE_FILE = "*** Update File: ";
|
||||
const DELETE_FILE = "*** Delete File: ";
|
||||
|
||||
const OP_LABEL: Record<string, string> = { add: "create", update: "edit", delete: "delete" };
|
||||
|
||||
interface PatchOp {
|
||||
kind: "add" | "update" | "delete";
|
||||
path: string;
|
||||
oldLines: string[];
|
||||
newLines: string[];
|
||||
}
|
||||
|
||||
/** apply_patch args arrive as {patch: text} (chat-completions FunctionTool) or
|
||||
* {input: text} (CustomTool). Mirrors the OSS `_extract_patch_text`. */
|
||||
function extractPatchText(args: Record<string, unknown>): string {
|
||||
const raw = args.patch;
|
||||
if (typeof raw === "string") return raw;
|
||||
if (raw && typeof raw === "object" && typeof (raw as Record<string, unknown>).patch === "string") {
|
||||
return (raw as Record<string, string>).patch;
|
||||
}
|
||||
return typeof args.input === "string" ? args.input : "";
|
||||
}
|
||||
|
||||
/** Parse V4A patch text into per-file operations (mirrors `_parse_patch_operations`). */
|
||||
function parsePatchOperations(patchText: string): PatchOp[] {
|
||||
const ops: PatchOp[] = [];
|
||||
let current: PatchOp | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (current) ops.push(current);
|
||||
current = null;
|
||||
};
|
||||
|
||||
for (const line of patchText.split("\n")) {
|
||||
if (line === BEGIN_PATCH || line === END_PATCH) continue;
|
||||
if (line.startsWith(ADD_FILE)) {
|
||||
flush();
|
||||
current = { kind: "add", path: line.slice(ADD_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (line.startsWith(UPDATE_FILE)) {
|
||||
flush();
|
||||
current = { kind: "update", path: line.slice(UPDATE_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (line.startsWith(DELETE_FILE)) {
|
||||
flush();
|
||||
current = { kind: "delete", path: line.slice(DELETE_FILE.length).trim(), oldLines: [], newLines: [] };
|
||||
} else if (current?.kind === "update") {
|
||||
if (line.startsWith("@@")) continue;
|
||||
if (line.startsWith("-") && !line.startsWith("---")) current.oldLines.push(line.slice(1));
|
||||
else if (line.startsWith("+") && !line.startsWith("+++")) current.newLines.push(line.slice(1));
|
||||
} else if (current?.kind === "add") {
|
||||
if (line.startsWith("+")) current.newLines.push(line.slice(1));
|
||||
else if (line.trim()) current.newLines.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return ops;
|
||||
}
|
||||
|
||||
function Operation({ op }: { op: PatchOp }) {
|
||||
const label = OP_LABEL[op.kind] ?? "file";
|
||||
const total = op.oldLines.length + op.newLines.length;
|
||||
const truncated = total > DIFF_PREVIEW_LINES;
|
||||
const oldBudget = truncated && total > 0 ? Math.round(DIFF_PREVIEW_LINES * (op.oldLines.length / total)) : op.oldLines.length;
|
||||
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : op.newLines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
|
||||
{op.path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(op.path)}</span>}
|
||||
</div>
|
||||
{(op.oldLines.length > 0 || op.newLines.length > 0) && (
|
||||
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
|
||||
{op.oldLines.slice(0, oldBudget).map((line, i) => (
|
||||
<div key={`o${i}`} className="text-red-400/60">
|
||||
<span className="select-none text-red-400/30 mr-1">-</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{op.newLines.slice(0, newBudget).map((line, i) => (
|
||||
<div key={`n${i}`} className="text-emerald-400/60">
|
||||
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{truncated && <div className="text-[#444] mt-0.5">... {total - DIFF_PREVIEW_LINES} more lines</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApplyPatchRenderer({ args, result, status }: ToolRendererProps) {
|
||||
const ops = parsePatchOperations(extractPatchText(args));
|
||||
|
||||
if (ops.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-sky-400/80 font-semibold text-sm">patch</span>
|
||||
{status === "failed" && typeof result === "string" && result.trim() && (
|
||||
<div className="text-red-400/70 text-[13px] mt-1">{result.trim()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{ops.map((op, i) => (
|
||||
<Operation key={i} op={op} />
|
||||
))}
|
||||
{status === "failed" && typeof result === "string" && result.trim() && (
|
||||
<div className="text-red-400/70 text-[13px]">{result.trim()}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const SIMPLE_ACTIONS: Record<string, string> = {
|
||||
back: "going back in browser history",
|
||||
forward: "going forward in browser history",
|
||||
scroll_down: "scrolling down",
|
||||
scroll_up: "scrolling up",
|
||||
refresh: "refreshing",
|
||||
close_tab: "closing tab",
|
||||
switch_tab: "switching tab",
|
||||
list_tabs: "listing tabs",
|
||||
view_source: "viewing page source",
|
||||
get_console_logs: "getting console logs",
|
||||
screenshot: "taking screenshot",
|
||||
wait: "waiting...",
|
||||
close: "closing",
|
||||
};
|
||||
|
||||
const CLICK_ACTIONS: Record<string, string> = {
|
||||
click: "clicking",
|
||||
double_click: "double clicking",
|
||||
hover: "hovering",
|
||||
};
|
||||
|
||||
function UrlLabel({ prefix, url, suffix }: { prefix: string; url?: string; suffix?: string }) {
|
||||
return (
|
||||
<span className="text-[#888] text-[13px]">
|
||||
{prefix}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-400/80 hover:underline"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function describeAction(args: Record<string, unknown>): React.ReactNode {
|
||||
const action = (args.action as string) ?? "";
|
||||
const url = (args.url as string) ?? undefined;
|
||||
|
||||
// Simple actions (no extra args)
|
||||
if (action in SIMPLE_ACTIONS) return SIMPLE_ACTIONS[action];
|
||||
|
||||
// URL actions: launch, goto, new_tab
|
||||
if (action === "launch") {
|
||||
if (!url) return "launching";
|
||||
return <UrlLabel prefix="launching " url={url} />;
|
||||
}
|
||||
if (action === "goto" || action === "navigate") {
|
||||
return <UrlLabel prefix="navigating to " url={url} />;
|
||||
}
|
||||
if (action === "new_tab") {
|
||||
return <UrlLabel prefix="opening tab " url={url} />;
|
||||
}
|
||||
|
||||
// Click actions
|
||||
if (action in CLICK_ACTIONS) return CLICK_ACTIONS[action];
|
||||
|
||||
// Type
|
||||
if (action === "type") {
|
||||
const text = ((args.text as string) ?? "").slice(0, 40);
|
||||
return `typing "${text}"`;
|
||||
}
|
||||
|
||||
// Key press
|
||||
if (action === "press_key" || action === "key_press") {
|
||||
return `pressing key ${(args.key as string) ?? ""}`;
|
||||
}
|
||||
|
||||
// Save PDF
|
||||
if (action === "save_pdf" || action === "save_as_pdf") {
|
||||
const path = (args.file_path as string) ?? "";
|
||||
return `saving PDF${path ? ` to ${path}` : ""}`;
|
||||
}
|
||||
|
||||
// Execute JS — description only, code shown separately
|
||||
if (action === "execute_js") return "executing javascript";
|
||||
|
||||
return action || "browser action";
|
||||
}
|
||||
|
||||
export default function BrowserRenderer({ args }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const jsCode = action === "execute_js"
|
||||
? ((args.js_code as string) ?? (args.code as string) ?? "")
|
||||
: "";
|
||||
const description = describeAction(args);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-blue-400/80 font-semibold text-sm shrink-0">Browser</span>
|
||||
<span className="min-w-0 truncate text-[#888] text-[13px]">
|
||||
{typeof description === "string" ? description : description}
|
||||
</span>
|
||||
</div>
|
||||
{jsCode && <SyntaxBlock code={jsCode} language="javascript" collapsible />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
interface ChatBubbleProps {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MAX_LINES = 30;
|
||||
|
||||
export default function ChatBubble({ role, content }: ChatBubbleProps) {
|
||||
const isUser = role === "user" || role === "human";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className={`font-semibold text-sm ${isUser ? "text-blue-400/80" : "text-purple-400/80"}`}>
|
||||
{isUser ? "User" : "Thinking"}
|
||||
</span>
|
||||
<div className="mt-1.5 italic text-[#888]">
|
||||
<TruncatedText text={content} maxLines={MAX_LINES} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock } from "./ToolCard";
|
||||
|
||||
/**
|
||||
* Generic renderer for tool names without a dedicated family renderer. Shows the
|
||||
* humanized tool name plus a pretty-printed dump of args/result. Tolerates the
|
||||
* server sending args/result as either a parsed object or an unparseable
|
||||
* Python-repr string (which arrives here wrapped as { __raw }); never crashes.
|
||||
*/
|
||||
function pretty(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string") return value.trim() ? value : null;
|
||||
if (typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
if (typeof rec.__raw === "string") return rec.__raw;
|
||||
if (Object.keys(rec).length === 0) return null;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function FallbackRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const argsText = pretty(args);
|
||||
const resultText = pretty(result);
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[#888] font-semibold text-sm">{toolName.replace(/_/g, " ")}</span>
|
||||
{argsText && <CodeBlock className="text-[#777]">{argsText}</CodeBlock>}
|
||||
{resultText && <CodeBlock className="text-[#666]">{resultText}</CodeBlock>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
const DIFF_PREVIEW_LINES = 30;
|
||||
|
||||
export default function FileEditRenderer({ toolName, args }: ToolRendererProps) {
|
||||
const filePath = (args.path as string) ?? (args.file_path as string) ?? "";
|
||||
const command = (args.command as string) ?? "";
|
||||
const oldStr = (args.old_str as string) ?? "";
|
||||
const newStr = (args.new_str as string) ?? "";
|
||||
const regex = (args.regex as string) ?? "";
|
||||
|
||||
let label: string;
|
||||
if (toolName === "list_files") label = "list";
|
||||
else if (toolName === "search_files") label = "search";
|
||||
else if (command === "view") label = "view";
|
||||
else if (command === "create") label = "create";
|
||||
else if (command === "str_replace") label = "edit";
|
||||
else if (command === "undo_edit") label = "undo";
|
||||
else if (command === "insert") label = "insert";
|
||||
else label = "file";
|
||||
|
||||
const pathDisplay = filePath ? shortPath(filePath) : "";
|
||||
const regexDisplay = regex ? ` /${regex}/` : "";
|
||||
|
||||
const oldLines = oldStr ? oldStr.split("\n") : [];
|
||||
const newLines = newStr ? newStr.split("\n") : [];
|
||||
const totalLines = oldLines.length + newLines.length;
|
||||
const truncated = totalLines > DIFF_PREVIEW_LINES;
|
||||
|
||||
// If truncated, split the budget proportionally
|
||||
const oldBudget = truncated ? Math.round(DIFF_PREVIEW_LINES * (oldLines.length / totalLines)) : oldLines.length;
|
||||
const newBudget = truncated ? DIFF_PREVIEW_LINES - oldBudget : newLines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">{label}</span>
|
||||
{pathDisplay && <span className="text-[#888] font-mono text-[13px] break-all">{pathDisplay}</span>}
|
||||
</div>
|
||||
{regexDisplay && (
|
||||
<div className="text-purple-400/60 font-mono text-[13px] break-all mt-0.5">{regexDisplay}</div>
|
||||
)}
|
||||
{(oldStr || newStr) && (
|
||||
<div className="font-mono text-[13px] leading-relaxed mt-1.5">
|
||||
{oldLines.slice(0, oldBudget).map((line, i) => (
|
||||
<div key={`o${i}`} className="text-red-400/60">
|
||||
<span className="select-none text-red-400/30 mr-1">-</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{newLines.slice(0, newBudget).map((line, i) => (
|
||||
<div key={`n${i}`} className="text-emerald-400/60">
|
||||
<span className="select-none text-emerald-400/30 mr-1">+</span>{line}
|
||||
</div>
|
||||
))}
|
||||
{truncated && (
|
||||
<div className="text-[#444] mt-0.5">... {totalLines - DIFF_PREVIEW_LINES} more lines</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function FinishRenderer({ args }: ToolRendererProps) {
|
||||
const executiveSummary = (args.executive_summary as string) ?? "";
|
||||
const methodology = (args.methodology as string) ?? "";
|
||||
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
||||
const recommendations = (args.recommendations as string) ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Penetration test completed</span>
|
||||
{executiveSummary && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Executive Summary</span><div className="mt-1"><TruncatedText text={executiveSummary} maxLines={25} /></div></div>
|
||||
)}
|
||||
{methodology && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Methodology</span><div className="mt-1"><TruncatedText text={methodology} maxLines={25} /></div></div>
|
||||
)}
|
||||
{technicalAnalysis && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span><div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={25} /></div></div>
|
||||
)}
|
||||
{recommendations && (
|
||||
<div><span className="text-emerald-400/60 text-sm font-semibold">Recommendations</span><div className="mt-1"><TruncatedText text={recommendations} maxLines={25} /></div></div>
|
||||
)}
|
||||
{!executiveSummary && !methodology && !technicalAnalysis && !recommendations && (
|
||||
<div className="text-[#555] text-xs">Generating final report...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
|
||||
export default function LoadSkillRenderer({ args }: ToolRendererProps) {
|
||||
// `skills` may arrive as an array of names or a comma-separated string
|
||||
// depending on the tool call, so normalize both to a clean list.
|
||||
const raw = args.skills;
|
||||
const requestedSkills = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
|
||||
.map((skill) => String(skill).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Loading skill</span>
|
||||
{requestedSkills.length > 0 && (
|
||||
<span className="text-[#888] text-[13px]">{requestedSkills.join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { rehypeCodeMeta, mdComponents } from "@/components/vulnerability/MdCodeBlock";
|
||||
|
||||
interface MarkdownProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Markdown({ text, className = "" }: MarkdownProps) {
|
||||
return (
|
||||
<div className={`prose-markdown ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
export default function NotesRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
if (toolName === "create_note") {
|
||||
const title = (args.title as string) ?? "";
|
||||
const content = (args.content as string) ?? "";
|
||||
const category = (args.category as string) ?? "general";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note</span>
|
||||
<span className="text-[#555] text-[13px]">({category})</span>
|
||||
</div>
|
||||
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
|
||||
{content && <div className="mt-1"><Markdown text={content} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "delete_note") {
|
||||
return <span className="text-amber-400/80 font-semibold text-sm">note removed</span>;
|
||||
}
|
||||
|
||||
if (toolName === "update_note") {
|
||||
const title = (args.title as string) ?? "";
|
||||
const content = (args.content as string) ?? "";
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note updated</span>
|
||||
{title && <div className="mt-1.5 text-[#999] text-[13px]">{title}</div>}
|
||||
{content && <div className="mt-1"><Markdown text={content} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "get_note") {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const note = res && typeof res === "object" && res.success
|
||||
? (res.note as Record<string, string> | undefined)
|
||||
: undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">note read</span>
|
||||
{note && (
|
||||
<>
|
||||
<div className="mt-1.5 text-[#999] text-[13px]">
|
||||
{note.title ?? "(untitled)"}
|
||||
<span className="text-[#555] ml-1">({note.category ?? "general"})</span>
|
||||
{(note.by_you || note.agent_name) && (
|
||||
<span className="text-[#666] ml-1 text-xs">by {note.by_you ? "you" : note.agent_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{note.content && <div className="mt-1"><Markdown text={note.content} /></div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "list_notes") {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
let notes: Array<Record<string, string>> = [];
|
||||
if (res && typeof res === "object" && res.success) {
|
||||
const rawNotes = res.notes;
|
||||
notes = Array.isArray(rawNotes) ? rawNotes as Array<Record<string, string>> : [];
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">notes</span>
|
||||
{notes.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{notes.map((n, i) => (
|
||||
<div key={i} className="text-[13px]">
|
||||
<span className="text-[#555] mr-1">-</span>
|
||||
<span className="text-[#999]">{n.title ?? "(untitled)"}</span>
|
||||
<span className="text-[#555] ml-1">({n.category ?? "general"})</span>
|
||||
{(n.by_you || n.agent_name) && (
|
||||
<span className="text-[#666] ml-1 text-xs">by {n.by_you ? "you" : n.agent_name}</span>
|
||||
)}
|
||||
{n.content && <div className="ml-3"><Markdown text={n.content} /></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="mt-1 text-[#555] text-xs">No notes</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-amber-400/80 font-semibold text-sm">note</span>;
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock } from "./ToolCard";
|
||||
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "text-emerald-400/80", POST: "text-blue-400/80", PUT: "text-yellow-400/80",
|
||||
PATCH: "text-orange-400/80", DELETE: "text-red-400/80",
|
||||
};
|
||||
|
||||
function statusColor(code: number): string {
|
||||
if (code < 300) return "text-emerald-400/80";
|
||||
if (code < 400) return "text-yellow-400/80";
|
||||
if (code < 500) return "text-orange-400/80";
|
||||
return "text-red-400/80";
|
||||
}
|
||||
|
||||
/** Hard truncate with trailing "..." */
|
||||
function trunc(text: string, maxLen = 80): string {
|
||||
return text.length > maxLen ? text.slice(0, maxLen - 3) + "..." : text;
|
||||
}
|
||||
|
||||
/** Replace newlines/tabs, then truncate */
|
||||
function sanitize(text: string, maxLen = 150): string {
|
||||
return trunc(text.replace(/\n/g, " ").replace(/\r/g, "").replace(/\t/g, " "), maxLen);
|
||||
}
|
||||
|
||||
/** Limit body to maxLines, each truncated to MAX_LINE_LENGTH-5; returns display string */
|
||||
function limitBody(body: string, maxLines: number): string {
|
||||
const lines = body.split("\n");
|
||||
const display = lines.slice(0, maxLines).map(l => trunc(l, MAX_LINE_LENGTH - 5)).join("\n");
|
||||
return lines.length > maxLines ? display + "\n..." : display;
|
||||
}
|
||||
|
||||
function ListRequests({ args, result }: ToolRendererProps) {
|
||||
const filter = (args.httpql_filter as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawReqs = res ? res.requests : null;
|
||||
const requests = Array.isArray(rawReqs) ? rawReqs as Array<Record<string, unknown>> : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">listing requests</span>
|
||||
{filter && <span className="text-[#888] text-[13px]">{trunc(filter, 150)}</span>}
|
||||
</div>
|
||||
{requests.length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
{requests.slice(0, 20).map((r, i) => {
|
||||
const m = ((r.method as string) ?? "GET").toUpperCase();
|
||||
const host = (r.host as string) ?? "";
|
||||
const path = (r.path as string) ?? "";
|
||||
const resp = r.response as Record<string, unknown> | undefined;
|
||||
const sc = (resp?.statusCode as number) ?? null;
|
||||
return (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className={`w-10 shrink-0 font-bold ${METHOD_COLORS[m] ?? "text-[#888]"}`}>{m}</span>
|
||||
<span className="text-[#777] truncate">{trunc(host + path, 180)}</span>
|
||||
{sc != null && <span className={`ml-auto shrink-0 ${statusColor(sc)}`}>{sc}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{requests.length > 20 && <div className="text-[#555]">... +{requests.length - 20} more</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewRequest({ args, result }: ToolRendererProps) {
|
||||
const requestId = args.request_id as number | undefined;
|
||||
const part = (args.part as string) ?? "request";
|
||||
const searchPattern = (args.search_pattern as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawMatches = res ? res.matches : null;
|
||||
const matches = Array.isArray(rawMatches) ? rawMatches as Array<Record<string, string>> : [];
|
||||
const content = res ? (res.content as string) ?? null : null;
|
||||
const hasMore = res ? !!(res.has_more) : false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{searchPattern ? "searching" : "viewing"} {part}</span>
|
||||
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
|
||||
{searchPattern && <span className="text-[#666] font-mono text-[13px]">/{trunc(searchPattern, 100)}/</span>}
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-1">
|
||||
{matches.slice(0, 5).map((m, i) => {
|
||||
// Sanitize context: replace newlines with space, trim to 100 chars
|
||||
const before = ((m.before ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(-100);
|
||||
const after = ((m.after ?? "").replace(/\n/g, " ").replace(/\r/g, "")).slice(0, 100);
|
||||
return (
|
||||
<div key={i}>
|
||||
{before && <span className="text-[#555]">...{before}</span>}
|
||||
<span className="text-amber-400/80 font-bold">{m.match}</span>
|
||||
{after && <span className="text-[#555]">{after}...</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{matches.length > 5 && <div className="text-[#555]">... +{matches.length - 5} more matches</div>}
|
||||
</div>
|
||||
)}
|
||||
{content && !matches.length && (() => {
|
||||
const lines = content.split("\n");
|
||||
const display = lines.slice(0, 15).map(l => trunc(l, MAX_LINE_LENGTH)).join("\n");
|
||||
const showMore = hasMore || lines.length > 15;
|
||||
return (
|
||||
<CodeBlock className="text-[#666]">
|
||||
{display + (showMore ? "\n... more content available" : "")}
|
||||
</CodeBlock>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SendRequest({ args, result }: ToolRendererProps) {
|
||||
const method = ((args.method as string) ?? "GET").toUpperCase();
|
||||
const url = (args.url as string) ?? "";
|
||||
const headers = args.headers as Record<string, string> | undefined;
|
||||
const rawBody = args.body;
|
||||
const reqBody = typeof rawBody === "string" ? rawBody : "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const error = res ? (res.error as string) ?? null : null;
|
||||
const statusCode = res ? (res.status_code as number) ?? null : null;
|
||||
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
|
||||
const rawResBody = res ? res.body : null;
|
||||
const resBody = typeof rawResBody === "string" ? rawResBody : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-purple-400/80 font-semibold text-sm">request</span>
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
<div>
|
||||
<span className="text-[#555] select-none mr-1">>></span>
|
||||
<span className={`font-bold ${METHOD_COLORS[method] ?? "text-[#888]"}`}>{method}</span>
|
||||
<span className="text-[#888] ml-1 break-all">{trunc(url, 180)}</span>
|
||||
</div>
|
||||
{headers && typeof headers === "object" && Object.entries(headers).slice(0, 5).map(([k, v]) => (
|
||||
<div key={k} className="text-[#555] pl-5">{k}: {sanitize(String(v), 150)}</div>
|
||||
))}
|
||||
</div>
|
||||
{reqBody && (
|
||||
<CodeBlock className="text-[#888]">{limitBody(reqBody, 4)}</CodeBlock>
|
||||
)}
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{sanitize(error, 150)}</div>}
|
||||
{statusCode != null && (
|
||||
<div className="font-mono text-[13px] mt-1.5">
|
||||
<span className="text-[#555] select-none mr-1"><<</span>
|
||||
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
|
||||
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
{resBody && (
|
||||
<CodeBlock className="text-[#666]">{limitBody(resBody, 6)}</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeatRequest({ args, result }: ToolRendererProps) {
|
||||
const requestId = args.request_id as number | undefined;
|
||||
const modifications = args.modifications as Record<string, unknown> | undefined;
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const statusCode = res ? (res.status_code as number) ?? null : null;
|
||||
const responseTime = res ? (res.response_time_ms as number) ?? null : null;
|
||||
const rawRepBody = res ? res.body : null;
|
||||
const resBody = typeof rawRepBody === "string" ? rawRepBody : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">repeating request</span>
|
||||
{requestId != null && <span className="text-[#888] text-[13px]">#{requestId}</span>}
|
||||
</div>
|
||||
{modifications && typeof modifications === "object" && Object.keys(modifications).length > 0 && (
|
||||
<div className="mt-1.5 font-mono text-[13px] space-y-0.5">
|
||||
{Object.entries(modifications).slice(0, 5).map(([k, v]) => (
|
||||
<div key={k}><span className="text-orange-400/60">{k}:</span> <span className="text-[#777]">{sanitize(typeof v === "string" ? v : JSON.stringify(v), 150)}</span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{statusCode != null && (
|
||||
<div className="font-mono text-[13px] mt-1.5">
|
||||
<span className="text-[#555] select-none mr-1"><<</span>
|
||||
<span className={`font-bold ${statusColor(statusCode)}`}>{statusCode}</span>
|
||||
{responseTime != null && <span className="text-[#555] ml-2">{responseTime}ms</span>}
|
||||
</div>
|
||||
)}
|
||||
{resBody && (
|
||||
<CodeBlock className="text-[#666]">{limitBody(resBody, 5)}</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SCOPE_ACTION: Record<string, string> = {
|
||||
get: "getting", list: "listing", create: "creating", update: "updating", delete: "deleting",
|
||||
};
|
||||
|
||||
function ScopeRules({ args }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const scopeName = (args.scope_name as string) ?? "";
|
||||
const label = SCOPE_ACTION[action] ?? (action ? action : "managing");
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{label} proxy scope</span>
|
||||
{scopeName && <span className="text-[#888] text-[13px]">{trunc(scopeName, 50)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListSitemap({ args }: ToolRendererProps) {
|
||||
const parentId = args.parent_id as string | undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">listing sitemap</span>
|
||||
{parentId && <span className="text-[#888] text-[13px]">under #{trunc(String(parentId), 20)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewSitemapEntry({ args }: ToolRendererProps) {
|
||||
const entryId = args.entry_id as string | undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400/80 font-semibold text-sm">viewing sitemap entry</span>
|
||||
{entryId && <span className="text-[#888] text-[13px]">#{trunc(String(entryId), 20)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProxyRenderer(props: ToolRendererProps) {
|
||||
switch (props.toolName) {
|
||||
case "list_requests": return <ListRequests {...props} />;
|
||||
case "view_request": return <ViewRequest {...props} />;
|
||||
case "send_request": return <SendRequest {...props} />;
|
||||
case "repeat_request": return <RepeatRequest {...props} />;
|
||||
case "scope_rules": return <ScopeRules {...props} />;
|
||||
case "list_sitemap": return <ListSitemap {...props} />;
|
||||
case "view_sitemap_entry": return <ViewSitemapEntry {...props} />;
|
||||
default:
|
||||
return (
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{props.toolName.replace(/_/g, " ")}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock, SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const MAX_OUTPUT_LINES = 50;
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
const HEAD = 25;
|
||||
const TAIL = 24;
|
||||
|
||||
// Full ANSI escape sequence pattern (matches Python's ANSI_PATTERN)
|
||||
const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g;
|
||||
|
||||
// Strips truncation notices added by Python executor
|
||||
const STRIP_PATTERN = /\.\.\. \[(stdout|stderr|result|output|error) truncated at \d+k? chars\]/g;
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(ANSI_PATTERN, "");
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
const clean = stripAnsi(line);
|
||||
if (clean.length > MAX_LINE_LENGTH) return clean.slice(0, MAX_LINE_LENGTH - 3) + "...";
|
||||
return clean;
|
||||
}
|
||||
|
||||
function cleanOutput(output: string): string {
|
||||
return output.replace(STRIP_PATTERN, "").trim();
|
||||
}
|
||||
|
||||
function formatOutput(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
|
||||
const hiddenCount = lines.length - HEAD - TAIL;
|
||||
return [
|
||||
...lines.slice(0, HEAD).map(truncateLine),
|
||||
`... ${hiddenCount} lines truncated ...`,
|
||||
...lines.slice(-TAIL).map(truncateLine),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function PythonRenderer({ args, result }: ToolRendererProps) {
|
||||
const action = (args.action as string) ?? "";
|
||||
const code = (args.code as string) ?? (args.script as string) ?? "";
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let stdout: string | null = null;
|
||||
if (res && typeof res === "object") stdout = typeof res.stdout === "string" ? res.stdout : null;
|
||||
else if (typeof res === "string") stdout = res;
|
||||
|
||||
const subtitle =
|
||||
action === "new_session" ? "new session" :
|
||||
action === "close" ? "close session" :
|
||||
action === "list_sessions" ? "list sessions" : null;
|
||||
|
||||
const output = stdout ? formatOutput(cleanOutput(stdout)) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-yellow-400/80 font-semibold text-sm">Python</span>
|
||||
{subtitle && <span className="text-[#888] text-[13px]">{subtitle}</span>}
|
||||
</div>
|
||||
{code && <SyntaxBlock code={code} language="python" collapsible />}
|
||||
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
|
||||
low: "text-blue-400", info: "text-cyan-400", none: "text-[#888]",
|
||||
};
|
||||
|
||||
interface ReportEntry {
|
||||
id?: string;
|
||||
title?: string;
|
||||
severity?: string;
|
||||
cvss?: number;
|
||||
cve?: string;
|
||||
cwe?: string;
|
||||
target?: string;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
description_preview?: string;
|
||||
description?: string;
|
||||
agent_name?: string;
|
||||
by_you?: boolean;
|
||||
}
|
||||
|
||||
function authorTag(r: ReportEntry) {
|
||||
if (!r.agent_name && !r.by_you) return null;
|
||||
const label = r.by_you ? "you" : r.agent_name;
|
||||
return <span className="text-[#666] text-xs ml-1.5">({label})</span>;
|
||||
}
|
||||
|
||||
function sevBadge(severity: string | undefined) {
|
||||
const sev = String(severity ?? "").toLowerCase();
|
||||
const color = SEVERITY_COLORS[sev] ?? "text-yellow-400";
|
||||
return <span className={`font-semibold text-[13px] ${color}`}>{sev.toUpperCase() || "—"}</span>;
|
||||
}
|
||||
|
||||
export default function ReportListRenderer({ toolName, result }: ToolRendererProps) {
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const ok = res != null && typeof res === "object" && res.success === true;
|
||||
|
||||
if (toolName === "get_report") {
|
||||
const report = ok ? (res.report as ReportEntry | undefined) : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-red-400/80 font-semibold text-sm">report</span>
|
||||
{report ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{sevBadge(report.severity)}
|
||||
{report.cvss != null && <span className="text-[#888] text-[13px]">CVSS {report.cvss}</span>}
|
||||
{report.id && <span className="text-[#555] font-mono text-[13px]">{report.id}</span>}
|
||||
{report.cve && <span className="text-[#888] font-mono text-[13px]">{report.cve}</span>}
|
||||
{report.cwe && <span className="text-[#888] font-mono text-[13px]">{report.cwe}</span>}
|
||||
{(report.agent_name || report.by_you) && (
|
||||
<span className="text-[#666] text-[13px]">{report.by_you ? "you" : report.agent_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{report.title && <div className="text-[15px] text-white/80 font-semibold">{report.title}</div>}
|
||||
{(report.target || report.endpoint) && (
|
||||
<div className="text-[13px] text-[#888] font-mono">
|
||||
{report.target}{report.endpoint ? ` ${report.method ?? ""} ${report.endpoint}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{report.description && <TruncatedText text={report.description} maxLines={20} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[#555] text-xs">
|
||||
{(res && typeof res === "object" && (res.error as string)) || "Report not found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// list_reports
|
||||
const rawReports = ok ? res.reports : null;
|
||||
const reports: ReportEntry[] = Array.isArray(rawReports) ? (rawReports as ReportEntry[]) : [];
|
||||
const total = ok && typeof res.total_count === "number" ? (res.total_count as number) : reports.length;
|
||||
const counts = ok && res.severity_counts && typeof res.severity_counts === "object"
|
||||
? (res.severity_counts as Record<string, number>)
|
||||
: {};
|
||||
const countEntries = Object.entries(counts);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-red-400/80 font-semibold text-sm">reports</span>
|
||||
<span className="text-[#555] text-[13px]">({total})</span>
|
||||
{countEntries.map(([sev, n]) => (
|
||||
<span key={sev} className="text-[13px]">
|
||||
{sevBadge(sev)}<span className="text-[#888] ml-0.5">{n}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{reports.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{reports.map((r, i) => (
|
||||
<div key={r.id ?? i} className="text-[13px]">
|
||||
<span className="text-[#555] mr-1">-</span>
|
||||
{sevBadge(r.severity)}
|
||||
{r.id && <span className="text-[#555] font-mono ml-1.5">{r.id}</span>}
|
||||
<span className="text-[#999] ml-1.5">{r.title ?? "(untitled)"}</span>
|
||||
{authorTag(r)}
|
||||
{(r.target || r.endpoint) && (
|
||||
<div className="ml-3 text-[#666] font-mono text-xs">
|
||||
{r.target}{r.endpoint ? ` ${r.method ?? ""} ${r.endpoint}` : ""}
|
||||
</div>
|
||||
)}
|
||||
{r.description_preview && (
|
||||
<div className="ml-3"><Markdown text={r.description_preview} /></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="mt-1 text-[#555] text-xs">No reports filed yet</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
function ScanStartInfo({ args }: ToolRendererProps) {
|
||||
const rawTargets = args.targets;
|
||||
const targets = Array.isArray(rawTargets) ? rawTargets : [];
|
||||
const targetNames = targets.map((t) => (typeof t === "object" && t ? (t.original as string) ?? null : null)).filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">Starting penetration test</span>
|
||||
{targetNames.length === 1 && <span className="text-[#888] text-[13px]">on {targetNames[0]}</span>}
|
||||
</div>
|
||||
{targetNames.length > 1 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{targetNames.map((t, i) => (
|
||||
<div key={i} className="text-[13px] text-[#888]"><span className="text-[#555] mr-1">•</span>{t}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentStartInfo({ args }: ToolRendererProps) {
|
||||
const name = (args.name as string) ?? "Unknown Agent";
|
||||
const task = (args.task as string) ?? "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[#888] text-[13px]">subagent</span>
|
||||
<span className="text-purple-400 font-semibold text-sm">{name}</span>
|
||||
</div>
|
||||
{task && <div className="mt-1.5"><TruncatedText text={task} maxLines={15} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScanInfoRenderer(props: ToolRendererProps) {
|
||||
if (props.toolName === "subagent_start_info") return <SubagentStartInfo {...props} />;
|
||||
return <ScanStartInfo {...props} />;
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { CodeBlock, SyntaxBlock } from "./ToolCard";
|
||||
|
||||
const MAX_OUTPUT_LINES = 50;
|
||||
const MAX_LINE_LENGTH = 200;
|
||||
const HEAD = 25;
|
||||
const TAIL = 24;
|
||||
|
||||
const STRIP_PATTERNS: RegExp[] = [
|
||||
/\n?\[Command still running after [\d.]+s - showing output so far\.?\s*(?:Use C-c to interrupt if needed\.)?\]/g,
|
||||
/^\[Below is the output of the previous command\.\]\n?/gm,
|
||||
/^No command is currently running\. Cannot send input\.$/gm,
|
||||
/^A command is already running\. Use is_input=true to send input to it, or interrupt it first \(e\.g\., with C-c\)\.$/gm,
|
||||
];
|
||||
|
||||
// Terminal-tool chunk metadata (the OSS engine's shell tool prepends these; the
|
||||
// TUI strips them in strix/interface/tui/renderers/shell_renderer.py). Only a
|
||||
// contiguous block anchored on a "Chunk ID:" line is stripped, so identical
|
||||
// text inside real command output is left untouched.
|
||||
const CHUNK_PREAMBLE_START = /^Chunk ID: [0-9a-f]+\s*$/;
|
||||
const CHUNK_PREAMBLE_METADATA: RegExp[] = [
|
||||
/^Wall time: [\d.]+ seconds\s*$/,
|
||||
/^Process exited with code -?\d+\s*$/,
|
||||
/^Process running with session ID \d+\s*$/,
|
||||
/^Original token count: \d+\s*$/,
|
||||
];
|
||||
|
||||
function stripChunkPreambles(lines: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (CHUNK_PREAMBLE_START.test(lines[i])) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && CHUNK_PREAMBLE_METADATA.some((p) => p.test(lines[j]))) j++;
|
||||
if (j < lines.length && lines[j].trim() === "Output:") j++;
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
out.push(lines[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
if (line.length > MAX_LINE_LENGTH) return line.slice(0, MAX_LINE_LENGTH - 3) + "...";
|
||||
return line;
|
||||
}
|
||||
|
||||
function cleanOutput(raw: string, command: string = ""): string {
|
||||
// Strip ANSI escape sequences and carriage returns
|
||||
let cleaned = raw.replace(/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)/g, "").replace(/\r/g, "");
|
||||
|
||||
for (const pattern of STRIP_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern, "");
|
||||
}
|
||||
|
||||
if (cleaned.trim()) {
|
||||
const lines = stripChunkPreambles(cleaned.split("\n"));
|
||||
const filtered: string[] = [];
|
||||
for (const line of lines) {
|
||||
// Skip leading blank lines
|
||||
if (filtered.length === 0 && !line.trim()) continue;
|
||||
// Skip [STRIX_N]$ prompt lines
|
||||
if (/^\[STRIX_\d+\]\$\s*/.test(line)) continue;
|
||||
// Skip echoed command (plain)
|
||||
if (command && line.trim() === command.trim()) continue;
|
||||
// Skip echoed command with $/#/> prefix
|
||||
if (command && new RegExp(`^[\\$#>]\\s*${escapeRegex(command.trim())}\\s*$`).test(line)) continue;
|
||||
filtered.push(line);
|
||||
}
|
||||
// Strip trailing [STRIX_N]$ lines
|
||||
while (filtered.length > 0 && /^\[STRIX_\d+\]\$\s*/.test(filtered[filtered.length - 1])) {
|
||||
filtered.pop();
|
||||
}
|
||||
cleaned = filtered.join("\n");
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
function formatOutput(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES) return lines.map(truncateLine).join("\n");
|
||||
const hiddenCount = lines.length - HEAD - TAIL;
|
||||
return [
|
||||
...lines.slice(0, HEAD).map(truncateLine),
|
||||
`... ${hiddenCount} lines truncated ...`,
|
||||
...lines.slice(-TAIL).map(truncateLine),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function TerminalRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const isStdin = toolName === "write_stdin";
|
||||
const command = isStdin
|
||||
? ((args.chars as string) ?? (args.input as string) ?? "")
|
||||
: ((args.command as string) ?? (args.cmd as string) ?? "");
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let content: string | null = null;
|
||||
let error: string | null = null;
|
||||
let exitCode: number | null = null;
|
||||
|
||||
if (res && typeof res === "object") {
|
||||
content = typeof res.content === "string" ? res.content : null;
|
||||
error = typeof res.error === "string" ? res.error : null;
|
||||
exitCode = typeof res.exit_code === "number" ? res.exit_code : null;
|
||||
const s = typeof res.status === "string" ? res.status : "";
|
||||
if (s === "running" || s === "command still running") content = null;
|
||||
} else if (typeof res === "string") {
|
||||
content = res;
|
||||
}
|
||||
|
||||
const output = content ? formatOutput(cleanOutput(content, command)) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-emerald-400/80 font-semibold text-sm">{isStdin ? "Terminal input" : "Terminal"}</span>
|
||||
{command && <SyntaxBlock code={command} language="bash" collapsible />}
|
||||
{error && <CodeBlock className="text-red-400/70">{error}</CodeBlock>}
|
||||
{output && <CodeBlock className="text-[#666]">{output}</CodeBlock>}
|
||||
{exitCode != null && exitCode !== 0 && (
|
||||
<div className="font-mono text-[13px] text-red-400/70 mt-0.5">exit code {exitCode}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function ThinkRenderer({ args }: ToolRendererProps) {
|
||||
const thought = (args.thought as string) ?? (args.content as string) ?? "";
|
||||
if (!thought) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-purple-400/80 font-semibold text-sm">Agent is thinking</span>
|
||||
<div className="mt-1.5 italic text-[#888]">
|
||||
<TruncatedText text={thought} maxLines={20} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { Circle, CircleDot, CircleCheckBig, Trash2, Plus, RefreshCw, CheckCheck, RotateCcw, Pencil } from "lucide-react";
|
||||
|
||||
interface TodoItem {
|
||||
id?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; Icon: typeof Circle }> = {
|
||||
create_todo: { label: "Task added", Icon: Plus },
|
||||
list_todos: { label: "Plan", Icon: CheckCheck },
|
||||
update_todo: { label: "Task updated", Icon: Pencil },
|
||||
mark_todo_done: { label: "Task completed", Icon: CircleCheckBig },
|
||||
mark_todo_pending: { label: "Task reopened", Icon: RotateCcw },
|
||||
delete_todo: { label: "Task removed", Icon: Trash2 },
|
||||
};
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === "done") return <CircleCheckBig className="w-3.5 h-3.5 text-emerald-400/70 shrink-0" />;
|
||||
if (status === "in_progress") return <CircleDot className="w-3.5 h-3.5 text-purple-400/70 shrink-0 animate-pulse" />;
|
||||
return <Circle className="w-3.5 h-3.5 text-[#444] shrink-0" />;
|
||||
}
|
||||
|
||||
function TodoList({ todos, highlightId }: { todos: TodoItem[]; highlightId?: string }) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{todos.map((todo, i) => {
|
||||
const s = todo.status ?? "pending";
|
||||
const isHighlighted = highlightId && todo.id === highlightId;
|
||||
return (
|
||||
<div
|
||||
key={todo.id ?? i}
|
||||
className={`flex items-start gap-2.5 py-1.5 px-2 -mx-2 rounded-md transition-colors ${
|
||||
isHighlighted ? "bg-purple-500/[0.08]" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mt-[1px]">
|
||||
<StatusIcon status={s} />
|
||||
</div>
|
||||
<span
|
||||
className={`text-[13px] leading-snug ${
|
||||
s === "done"
|
||||
? "text-[#555] line-through"
|
||||
: s === "in_progress"
|
||||
? "text-[#bbb]"
|
||||
: "text-[#999]"
|
||||
}`}
|
||||
>
|
||||
{todo.title ?? "(untitled)"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodoRenderer({ toolName, args, result }: ToolRendererProps) {
|
||||
const action = ACTION_LABELS[toolName] ?? { label: "Plan", Icon: RefreshCw };
|
||||
const ActionIcon = action.Icon;
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
|
||||
// Simple string result
|
||||
if (typeof res === "string" && res.trim()) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[#888] text-[13px]">{res.trim()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse structured result
|
||||
let todos: TodoItem[] = [];
|
||||
let error: string | null = null;
|
||||
let todoId: string | undefined;
|
||||
|
||||
if (res && typeof res === "object") {
|
||||
error = (res.error as string) ?? null;
|
||||
if (res.success) {
|
||||
const rawTodos = res.todos;
|
||||
todos = Array.isArray(rawTodos) ? (rawTodos as TodoItem[]) : [];
|
||||
}
|
||||
todoId = (res.id as string) ?? (args.todo_id as string) ?? undefined;
|
||||
}
|
||||
|
||||
// For mutations, highlight the affected item
|
||||
const highlightId = toolName !== "list_todos" ? todoId : undefined;
|
||||
|
||||
// No todos and no error — brief label only
|
||||
if (todos.length === 0 && !error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ActionIcon className="w-3.5 h-3.5 text-purple-400/60" />
|
||||
<span className="text-purple-400/80 font-semibold text-sm">{action.label}</span>
|
||||
</div>
|
||||
{error && <div className="text-red-400/70 text-[13px] mb-2">{error}</div>}
|
||||
{todos.length > 0 && (
|
||||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.015] px-3 py-2">
|
||||
<TodoList todos={todos} highlightId={highlightId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Markdown from "./Markdown";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
const OUTPUT_PREVIEW_LINES = 6;
|
||||
const CODE_PREVIEW_LINES = 20;
|
||||
|
||||
/** Truncatable markdown text with "Show more" */
|
||||
export function TruncatedText({ text, maxLines = 20 }: { text: string; maxLines?: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const lines = text.trimEnd().split("\n");
|
||||
const needsTruncation = lines.length > maxLines;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={expanded && needsTruncation ? "max-h-[1200px] overflow-auto" : ""}
|
||||
style={!expanded && needsTruncation ? { display: "-webkit-box", WebkitLineClamp: maxLines, WebkitBoxOrient: "vertical", overflow: "hidden" } : undefined}
|
||||
>
|
||||
<Markdown text={text} />
|
||||
</div>
|
||||
{needsTruncation && (
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-1">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Code/output block — truncates to 12 lines with "Show more", expanded view scrolls */
|
||||
export function CodeBlock({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isString = typeof children === "string";
|
||||
const lines = isString ? (children as string).trimEnd().split("\n") : null;
|
||||
const needsTruncation = lines !== null && lines.length > OUTPUT_PREVIEW_LINES;
|
||||
const displayContent = needsTruncation && !expanded
|
||||
? lines!.slice(0, OUTPUT_PREVIEW_LINES).join("\n")
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className={`font-mono text-[13px] leading-relaxed whitespace-pre-wrap break-words mt-1 ${
|
||||
expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
|
||||
} ${className}`}>
|
||||
{displayContent}
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-[#555] hover:text-[#888] mt-0.5"
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Syntax-highlighted code block — no border, no line numbers, just highlighting.
|
||||
* Pass `collapsible` to get a "Show more" toggle instead of a scroll cap. */
|
||||
export function SyntaxBlock({ code, language, className = "", collapsible = false }: { code: string; language?: string; className?: string; collapsible?: boolean }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lines = code.trimEnd().split("\n");
|
||||
const needsTruncation = collapsible && lines.length > CODE_PREVIEW_LINES;
|
||||
const displayCode = needsTruncation && !expanded
|
||||
? lines.slice(0, CODE_PREVIEW_LINES).join("\n")
|
||||
: code;
|
||||
|
||||
let highlighted: string;
|
||||
try {
|
||||
highlighted = language
|
||||
? hljs.highlight(displayCode, { language, ignoreIllegals: true }).value
|
||||
: hljs.highlightAuto(displayCode).value;
|
||||
} catch {
|
||||
highlighted = hljs.highlightAuto(displayCode).value;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<pre className={`font-mono text-[12px] leading-relaxed px-0 py-1 mt-1 whitespace-pre-wrap break-all ${
|
||||
collapsible
|
||||
? expanded ? "overflow-auto max-h-[1200px]" : "overflow-hidden"
|
||||
: "overflow-auto max-h-[400px]"
|
||||
} ${className}`}>
|
||||
<code dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button onClick={() => setExpanded(!expanded)} className="text-xs text-[#555] hover:text-[#888] mt-0.5">
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { shortPath } from "./utils";
|
||||
|
||||
/** Mirrors the OSS TUI `ViewImageRenderer`: surfaces load errors, otherwise a
|
||||
* compact "view image <path>" line. */
|
||||
export default function ViewImageRenderer({ args, result }: ToolRendererProps) {
|
||||
const path = ((args.path as string) ?? "").trim();
|
||||
|
||||
const res = result as Record<string, unknown> | string | null;
|
||||
let error: string | null = null;
|
||||
if (typeof res === "string") {
|
||||
const trimmed = res.trim();
|
||||
// A string result that isn't an image payload or structured data is an error message
|
||||
if (trimmed && !trimmed.toLowerCase().startsWith("data:image/") && !trimmed.startsWith("{")) {
|
||||
error = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sky-400/80 font-semibold text-sm shrink-0">view image</span>
|
||||
{path && <span className="text-[#888] font-mono text-[13px] break-all">{shortPath(path)}</span>}
|
||||
</div>
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
import { MdCodeBlock } from "@/components/vulnerability/MdCodeBlock";
|
||||
import { parseFencedCode } from "@/lib/fenced-code";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
|
||||
low: "text-blue-400", info: "text-cyan-400",
|
||||
};
|
||||
|
||||
export default function VulnReportRenderer({ args, result }: ToolRendererProps) {
|
||||
const title = (args.title as string) ?? "";
|
||||
const description = (args.description as string) ?? "";
|
||||
const impact = (args.impact as string) ?? "";
|
||||
const target = (args.target as string) ?? "";
|
||||
const endpoint = (args.endpoint as string) ?? "";
|
||||
const method = (args.method as string) ?? "";
|
||||
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
||||
const pocDescription = (args.poc_description as string) ?? "";
|
||||
const { language: pocLang, code: pocCode } = parseFencedCode((args.poc_script_code as string) ?? "");
|
||||
const remediation = (args.remediation_steps as string) ?? "";
|
||||
const cve = (args.cve as string) ?? "";
|
||||
const cwe = (args.cwe as string) ?? "";
|
||||
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const rawSev = (res && typeof res === "object" ? res.severity : null) ?? args.severity ?? "medium";
|
||||
const severity = String(rawSev).toLowerCase();
|
||||
const cvss = (res && typeof res === "object" ? (res.cvss_score as number) : null) ?? (args.cvss as number) ?? null;
|
||||
const sevColor = SEVERITY_COLORS[severity] ?? "text-yellow-400";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold text-sm ${sevColor}`}>{severity.toUpperCase()}</span>
|
||||
{cvss != null && <span className="text-[#888] text-[13px]">CVSS {cvss}</span>}
|
||||
{cve && <span className="text-[#888] font-mono text-[13px]">{cve}</span>}
|
||||
{cwe && <span className="text-[#888] font-mono text-[13px]">{cwe}</span>}
|
||||
</div>
|
||||
{title && <div className="text-[15px] text-white/80 font-semibold">{title}</div>}
|
||||
{(target || endpoint) && (
|
||||
<div className="text-[13px] text-[#888] font-mono">{target}{endpoint ? ` ${method} ${endpoint}` : ""}</div>
|
||||
)}
|
||||
{description && <TruncatedText text={description} maxLines={20} />}
|
||||
{impact && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Impact</span>
|
||||
<div className="mt-1"><TruncatedText text={impact} maxLines={15} /></div>
|
||||
</div>
|
||||
)}
|
||||
{technicalAnalysis && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Technical Analysis</span>
|
||||
<div className="mt-1"><TruncatedText text={technicalAnalysis} maxLines={20} /></div>
|
||||
</div>
|
||||
)}
|
||||
{(pocDescription || pocCode) && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
|
||||
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
|
||||
{pocCode && <MdCodeBlock className={pocLang ? `language-${pocLang}` : undefined}>{pocCode}</MdCodeBlock>}
|
||||
</div>
|
||||
)}
|
||||
{remediation && (
|
||||
<div>
|
||||
<span className="text-emerald-400/60 text-sm font-semibold">Remediation</span>
|
||||
<div className="mt-1"><TruncatedText text={remediation} maxLines={15} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import { TruncatedText } from "./ToolCard";
|
||||
|
||||
export default function WebSearchRenderer({ args, result }: ToolRendererProps) {
|
||||
const query = (args.query as string) ?? (args.search_query as string) ?? "";
|
||||
const res = result as Record<string, unknown> | null;
|
||||
const content = res ? (res.content as string) ?? null : null;
|
||||
const error = res && !res.success ? (res.message as string) ?? null : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-amber-400/80 font-semibold text-sm">Searching the web</span>
|
||||
{query && <div className="text-[#888] text-[13px] mt-0.5">{query}</div>}
|
||||
{error && <div className="text-red-400/70 text-[13px] mt-1.5">{error}</div>}
|
||||
{content && (
|
||||
<div className="mt-2">
|
||||
<TruncatedText text={content} maxLines={15} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolRendererProps } from "@/types/events";
|
||||
import {
|
||||
Terminal, Globe, FileText, ShieldAlert, ArrowUpRight, Brain,
|
||||
Bot, MessageCircle, Flag, Eye, Search, Code, StickyNote,
|
||||
ListTodo, Crosshair, Wrench, Ban, Image,
|
||||
} from "lucide-react";
|
||||
|
||||
import TerminalRenderer from "./TerminalRenderer";
|
||||
import BrowserRenderer from "./BrowserRenderer";
|
||||
import FileEditRenderer from "./FileEditRenderer";
|
||||
import ApplyPatchRenderer from "./ApplyPatchRenderer";
|
||||
import ViewImageRenderer from "./ViewImageRenderer";
|
||||
import VulnReportRenderer from "./VulnReportRenderer";
|
||||
import ReportListRenderer from "./ReportListRenderer";
|
||||
import ProxyRenderer from "./ProxyRenderer";
|
||||
import ThinkRenderer from "./ThinkRenderer";
|
||||
import AgentCommsRenderer from "./AgentCommsRenderer";
|
||||
import WebSearchRenderer from "./WebSearchRenderer";
|
||||
import PythonRenderer from "./PythonRenderer";
|
||||
import ScanInfoRenderer from "./ScanInfoRenderer";
|
||||
import FinishRenderer from "./FinishRenderer";
|
||||
import NotesRenderer from "./NotesRenderer";
|
||||
import TodoRenderer from "./TodoRenderer";
|
||||
import FallbackRenderer from "./FallbackRenderer";
|
||||
import LoadSkillRenderer from "./LoadSkillRenderer";
|
||||
|
||||
/**
|
||||
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
|
||||
*
|
||||
* The OSS strix engine (usestrix/strix) is the source of truth for tool names:
|
||||
* see `strix/tools/**` for definitions and `strix/interface/tui/renderers/` for
|
||||
* the TUI equivalents of these components. Tools come in families that share a
|
||||
* React renderer + icon (terminal, proxy, notes, todos, …), so we describe each
|
||||
* family ONCE instead of repeating a row per tool name. A new tool that joins an
|
||||
* existing family (e.g. another `*_request` proxy tool) is picked up by the
|
||||
* family prefix matcher with no code change; only genuinely-new families need an
|
||||
* entry here.
|
||||
*/
|
||||
|
||||
export type ToolCategory =
|
||||
| "terminal"
|
||||
| "python"
|
||||
| "browser"
|
||||
| "filesystem"
|
||||
| "proxy"
|
||||
| "reporting"
|
||||
| "thinking"
|
||||
| "agents"
|
||||
| "search"
|
||||
| "lifecycle"
|
||||
| "notes"
|
||||
| "skills"
|
||||
| "todos"
|
||||
| "telemetry";
|
||||
|
||||
export interface ToolIconMeta {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CategoryMeta {
|
||||
renderer: ComponentType<ToolRendererProps>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
/** Family matcher for graceful fallback of unknown tools in this family. */
|
||||
match?: RegExp;
|
||||
}
|
||||
|
||||
/** Per-family defaults: renderer + base icon/color + a family-name matcher. */
|
||||
const CATEGORY_META: Record<ToolCategory, CategoryMeta> = {
|
||||
terminal: { renderer: TerminalRenderer, icon: Terminal, color: "text-emerald-400" },
|
||||
python: { renderer: PythonRenderer, icon: Code, color: "text-yellow-400" },
|
||||
browser: { renderer: BrowserRenderer, icon: Globe, color: "text-blue-400" },
|
||||
filesystem: { renderer: FileEditRenderer, icon: FileText, color: "text-sky-400" },
|
||||
proxy: { renderer: ProxyRenderer, icon: ArrowUpRight, color: "text-purple-400", match: /request|sitemap|scope/ },
|
||||
reporting: { renderer: VulnReportRenderer, icon: ShieldAlert, color: "text-red-400" },
|
||||
thinking: { renderer: ThinkRenderer, icon: Brain, color: "text-purple-400" },
|
||||
agents: { renderer: AgentCommsRenderer, icon: Bot, color: "text-cyan-400", match: /agent/ },
|
||||
search: { renderer: WebSearchRenderer, icon: Search, color: "text-amber-400" },
|
||||
lifecycle: { renderer: ScanInfoRenderer, icon: Flag, color: "text-emerald-400" },
|
||||
notes: { renderer: NotesRenderer, icon: StickyNote, color: "text-amber-400", match: /note/ },
|
||||
skills: { renderer: LoadSkillRenderer, icon: Wrench, color: "text-emerald-400" },
|
||||
todos: { renderer: TodoRenderer, icon: ListTodo, color: "text-purple-400", match: /todo/ },
|
||||
telemetry: { renderer: FallbackRenderer, icon: Wrench, color: "text-[#555]" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool name → family. Grouped by family; legacy names the engine used before the
|
||||
* OSS SDK migration (terminal_execute, python_action, browser_action,
|
||||
* str_replace_editor, send_request, …) are kept as aliases so historical scan
|
||||
* data keeps rendering.
|
||||
*/
|
||||
const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
||||
// Shell — SDK `exec_command` / `write_stdin` (legacy: terminal_execute)
|
||||
terminal: ["exec_command", "write_stdin", "terminal_execute"],
|
||||
// Legacy Python session tool (now runs through the shell)
|
||||
python: ["python_action"],
|
||||
// Legacy browser tool (now driven via agent-browser CLI over the shell)
|
||||
browser: ["browser_action"],
|
||||
// SDK filesystem — `apply_patch` / `view_image` (legacy: str_replace_editor, list/search)
|
||||
filesystem: ["apply_patch", "view_image", "str_replace_editor", "list_files", "search_files"],
|
||||
// Caido proxy tools (legacy: send_request)
|
||||
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
||||
reporting: ["create_vulnerability_report", "list_reports", "get_report"],
|
||||
thinking: ["think"],
|
||||
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
|
||||
search: ["web_search"],
|
||||
// scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's
|
||||
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan"],
|
||||
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
|
||||
skills: ["load_skill"],
|
||||
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
|
||||
telemetry: ["sandbox_error_details", "llm_error_details"],
|
||||
};
|
||||
|
||||
/** Reverse index (tool name → family), built once from CATEGORY_TOOLS. */
|
||||
const TOOL_CATEGORY: Record<string, ToolCategory> = Object.fromEntries(
|
||||
(Object.entries(CATEGORY_TOOLS) as [ToolCategory, readonly string[]][]).flatMap(
|
||||
([category, names]) => names.map((name) => [name, category] as const),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-tool renderer overrides — for the rare tool whose renderer differs from its
|
||||
* family default (finish_scan renders the final report, not the scan-start card).
|
||||
*/
|
||||
const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps>>> = {
|
||||
finish_scan: FinishRenderer,
|
||||
apply_patch: ApplyPatchRenderer,
|
||||
view_image: ViewImageRenderer,
|
||||
list_reports: ReportListRenderer,
|
||||
get_report: ReportListRenderer,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-tool icon overrides — for tools whose icon/color differs from their family
|
||||
* default (the agents family and lifecycle family each vary per tool).
|
||||
*/
|
||||
const ICON_OVERRIDES: Partial<Record<string, ToolIconMeta>> = {
|
||||
agent_finish: { icon: Flag, color: "text-cyan-400" },
|
||||
send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" },
|
||||
wait_for_message: { icon: MessageCircle, color: "text-cyan-400" },
|
||||
view_agent_graph: { icon: Eye, color: "text-cyan-400" },
|
||||
stop_agent: { icon: Ban, color: "text-red-400" },
|
||||
scan_start_info: { icon: Crosshair, color: "text-emerald-400" },
|
||||
subagent_start_info: { icon: Bot, color: "text-purple-400" },
|
||||
view_image: { icon: Image, color: "text-sky-400" },
|
||||
};
|
||||
|
||||
const FALLBACK_META: CategoryMeta = CATEGORY_META.telemetry;
|
||||
|
||||
/** Resolve a tool name to its family, falling back to family-name matchers. */
|
||||
function resolveCategory(toolName: string): ToolCategory | null {
|
||||
const direct = TOOL_CATEGORY[toolName];
|
||||
if (direct) return direct;
|
||||
for (const [category, meta] of Object.entries(CATEGORY_META) as [ToolCategory, CategoryMeta][]) {
|
||||
if (meta.match?.test(toolName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getToolRenderer(toolName: string): ComponentType<ToolRendererProps> {
|
||||
const override = RENDERER_OVERRIDES[toolName];
|
||||
if (override) return override;
|
||||
const category = resolveCategory(toolName);
|
||||
return category ? CATEGORY_META[category].renderer : FallbackRenderer;
|
||||
}
|
||||
|
||||
export function getToolIcon(toolName: string): ToolIconMeta {
|
||||
const override = ICON_OVERRIDES[toolName];
|
||||
if (override) return override;
|
||||
const category = resolveCategory(toolName);
|
||||
const meta = category ? CATEGORY_META[category] : FALLBACK_META;
|
||||
return { icon: meta.icon, color: meta.color };
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function shortPath(p: string): string {
|
||||
return p.length > 60 ? "..." + p.slice(-57) : p;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import hljs from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { diffLines } from "diff";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { getLanguageFromFile, copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
|
||||
function safeHighlight(code: string, lang: string): string {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
}
|
||||
|
||||
interface CodeDiffBlockProps {
|
||||
file: string;
|
||||
startLine: number;
|
||||
endLine?: number;
|
||||
before: string;
|
||||
after: string;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function CodeDiffBlock({ file, startLine, endLine, before, after, onCopy }: CodeDiffBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const lineRef =
|
||||
endLine && endLine !== startLine ? `${startLine}-${endLine}` : `${startLine}`;
|
||||
const lang = getLanguageFromFile(file) || "text";
|
||||
const changes = diffLines(before, after);
|
||||
|
||||
let oldLineNo = startLine;
|
||||
let newLineNo = startLine;
|
||||
const rows = changes.flatMap((change) =>
|
||||
change.value
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const highlighted =
|
||||
line === ""
|
||||
? "\n"
|
||||
: lang !== "text"
|
||||
? safeHighlight(line, lang)
|
||||
: line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
let leftNo = "";
|
||||
let rightNo = "";
|
||||
if (change.removed) {
|
||||
leftNo = String(oldLineNo++);
|
||||
} else if (change.added) {
|
||||
rightNo = String(newLineNo++);
|
||||
} else {
|
||||
leftNo = String(oldLineNo++);
|
||||
rightNo = String(newLineNo++);
|
||||
}
|
||||
return { highlighted, added: !!change.added, removed: !!change.removed, leftNo, rightNo };
|
||||
})
|
||||
);
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(after);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[#2a2a2a] overflow-hidden">
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a] break-all">
|
||||
{file}:{lineRef}
|
||||
<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" />
|
||||
</span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
|
||||
aria-label="Copy fixed code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={row.added ? "bg-blue-500/[0.12]" : row.removed ? "bg-red-500/[0.12]" : ""}
|
||||
>
|
||||
<td className="select-none w-[1px] whitespace-nowrap pl-4 pr-1.5 text-right text-[#555] align-top text-[12px] leading-[22px]">
|
||||
{row.leftNo}
|
||||
</td>
|
||||
<td className="select-none w-[1px] whitespace-nowrap pl-1.5 pr-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
|
||||
{row.rightNo}
|
||||
</td>
|
||||
<td
|
||||
className="pl-4 pr-4 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: row.highlighted }}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
||||
|
||||
export function ContentSection({ title, content, action }: { title?: string; content: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
{(title || action) && (
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
{title ? <h2 className="text-xl font-semibold text-white">{title}</h2> : <span />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
<div className="prose-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Clock, Globe, ChevronDown } from "lucide-react";
|
||||
import { parseTarget } from "@/lib/target-utils";
|
||||
import { ProviderIcon } from "@/components/AddRepositoryDialog";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { FIX_EFFORT_META, type FixEffort, type Vulnerability } from "@/types/issues";
|
||||
|
||||
/* ─── Human-friendly CVSS labels ─── */
|
||||
|
||||
const HUMAN_LABELS: Record<string, Record<string, string>> = {
|
||||
attack_vector: { N: "Remotely exploitable", A: "Adjacent network", L: "Local access required", P: "Physical access required" },
|
||||
attack_complexity: { L: "Easy to exploit", H: "Requires specific conditions" },
|
||||
privileges_required: { N: "No authentication needed", L: "Low privileges needed", H: "High privileges needed" },
|
||||
user_interaction: { N: "No user action required", R: "Requires user action", P: "Passive user role", A: "Active user role" },
|
||||
scope: { U: "Impact stays contained", C: "Can spread to other systems" },
|
||||
confidentiality: { N: "No data exposure", L: "Partial data exposure", H: "Full data exposure" },
|
||||
integrity: { N: "No data modification", L: "Limited modification", H: "Full data modification" },
|
||||
availability: { N: "No service disruption", L: "Limited disruption", H: "Full service disruption" },
|
||||
};
|
||||
|
||||
const RISK_LEVEL: Record<string, Record<string, "low" | "medium" | "high">> = {
|
||||
attack_vector: { N: "high", A: "medium", L: "low", P: "low" },
|
||||
attack_complexity: { L: "high", H: "low" },
|
||||
privileges_required: { N: "high", L: "medium", H: "low" },
|
||||
user_interaction: { N: "high", R: "low", P: "medium", A: "low" },
|
||||
scope: { C: "high", U: "low" },
|
||||
confidentiality: { H: "high", L: "medium", N: "low" },
|
||||
integrity: { H: "high", L: "medium", N: "low" },
|
||||
availability: { H: "high", L: "medium", N: "low" },
|
||||
};
|
||||
|
||||
const RISK_BADGE: Record<string, string> = {
|
||||
high: "bg-red-500/15 text-red-400 border-red-500/25",
|
||||
medium: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
|
||||
low: "bg-[#222] text-[#666] border-[#333]",
|
||||
};
|
||||
|
||||
const FACTOR_GROUPS: { label: string; keys: string[] }[] = [
|
||||
{ label: "Exploitability", keys: ["attack_vector", "attack_complexity", "privileges_required", "user_interaction"] },
|
||||
{ label: "Impact", keys: ["scope", "confidentiality", "integrity", "availability"] },
|
||||
];
|
||||
|
||||
/* ─── Location link builder ─── */
|
||||
|
||||
export function buildLocationHref(
|
||||
repoUrl: string,
|
||||
provider: string,
|
||||
branch: string,
|
||||
file: string,
|
||||
startLine: number,
|
||||
): string | null {
|
||||
const base = repoUrl.replace(/\.git$/, "").replace(/\/+$/, "");
|
||||
const encodedFile = file.split("/").map(encodeURIComponent).join("/");
|
||||
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
if (provider === "github") {
|
||||
return `${base}/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
|
||||
}
|
||||
if (provider === "gitlab") {
|
||||
return `${base}/-/blob/${encodedBranch}/${encodedFile}#L${startLine}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ─── Props ─── */
|
||||
|
||||
interface IssueSidebarProps {
|
||||
vulnerability: Vulnerability;
|
||||
statusSlot: React.ReactNode;
|
||||
slackThreadUrl?: string | null;
|
||||
}
|
||||
|
||||
/* ─── Component ─── */
|
||||
|
||||
export function IssueSidebar({ vulnerability, statusSlot, slackThreadUrl }: IssueSidebarProps) {
|
||||
const { severity, cvss, cve, cwe, fix_effort, created_at, target, endpoint, method, code_locations, cvss_breakdown, location_meta } = vulnerability;
|
||||
|
||||
const [riskOpen, setRiskOpen] = useState(true);
|
||||
|
||||
const fixLocations = code_locations?.filter((loc) => loc.fix_before && loc.fix_after);
|
||||
const hasLocations = fixLocations && fixLocations.length > 0;
|
||||
const parsed = target ? parseTarget(target) : null;
|
||||
const hasAsset = !!(target || endpoint || method || hasLocations);
|
||||
|
||||
const hasBreakdown = cvss_breakdown && Object.values(cvss_breakdown).some((v) => v != null);
|
||||
|
||||
return (
|
||||
<aside className="lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
|
||||
{/* ─── Metadata ─── */}
|
||||
<div className="pb-4">
|
||||
<div className="space-y-3">
|
||||
{/* Severity */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Severity</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${getSeverityDot(severity)}`} aria-hidden="true" />
|
||||
<span className="text-sm font-medium capitalize text-white">{severity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CVSS */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CVSS Score</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-white">{cvss !== null ? cvss : "N/A"}</span>
|
||||
</div>
|
||||
|
||||
{/* CVE */}
|
||||
{cve && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CVE</span>
|
||||
<span className="text-sm text-white font-mono">{cve}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CWE */}
|
||||
{cwe && cwe.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">CWE</span>
|
||||
<span className="text-xs text-white font-mono truncate max-w-[80%] text-right" title={cwe.join(" · ")}>
|
||||
{cwe.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fix Effort */}
|
||||
{fix_effort && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Fix Effort</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full border ${FIX_EFFORT_META[fix_effort as FixEffort]?.color ?? "text-[#666]"}`}>
|
||||
{fix_effort.charAt(0).toUpperCase() + fix_effort.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Discovered</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-[#444]" aria-hidden="true" />
|
||||
<span className="text-sm text-white">{formatTimeAgo(created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Status</span>
|
||||
{statusSlot}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Asset ─── */}
|
||||
{hasAsset && (
|
||||
<div className="border-t border-[#191919] pt-4 pb-4">
|
||||
<p className="text-xs font-medium text-[#aaa] mb-2.5">Asset</p>
|
||||
<div className="space-y-2.5">
|
||||
{target && parsed && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{parsed.provider ? (
|
||||
<span className="flex-shrink-0 [&_svg]:w-3.5 [&_svg]:h-3.5" aria-hidden="true">
|
||||
<ProviderIcon provider={parsed.provider} />
|
||||
</span>
|
||||
) : (
|
||||
<Globe className="w-3.5 h-3.5 text-[#555] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{parsed.href ? (
|
||||
<a
|
||||
href={parsed.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-white hover:text-[#ccc] break-words min-w-0 transition-colors"
|
||||
>
|
||||
{parsed.display}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-white break-words min-w-0">{parsed.display}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{endpoint && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Endpoint</span>
|
||||
<span className="text-xs text-white font-mono truncate max-w-[75%] text-right">{endpoint}</span>
|
||||
</div>
|
||||
)}
|
||||
{method && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#aaa]">Method</span>
|
||||
<span className="text-xs text-white font-mono">{method}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasLocations && (
|
||||
<div>
|
||||
<span className="text-xs text-[#aaa] mb-1.5 block">Locations</span>
|
||||
<div className="space-y-0.5">
|
||||
{fixLocations!.map((loc, i) => {
|
||||
const label = `${loc.file}:${loc.start_line}`;
|
||||
const href = location_meta
|
||||
? buildLocationHref(location_meta.repo_url, location_meta.provider, location_meta.branch, loc.file, loc.start_line)
|
||||
: null;
|
||||
return href ? (
|
||||
<a
|
||||
key={`loc-${i}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[13px] text-[#888] hover:text-white font-mono break-all transition-colors block"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span key={`loc-${i}`} className="text-[13px] text-[#888] font-mono break-all block">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Risk Assessment (human-friendly CVSS) ─── */}
|
||||
{hasBreakdown && (
|
||||
<div className="border-t border-[#191919] pt-4">
|
||||
<button
|
||||
onClick={() => setRiskOpen(!riskOpen)}
|
||||
className="flex items-center justify-between w-full mb-2.5 group"
|
||||
aria-expanded={riskOpen}
|
||||
>
|
||||
<span className="text-xs font-medium text-[#aaa]">Risk Assessment</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-[#555] group-hover:text-white transition-transform ${riskOpen ? "" : "-rotate-90"}`} aria-hidden="true" />
|
||||
</button>
|
||||
<div className={`space-y-3 ${riskOpen ? "" : "hidden"}`}>
|
||||
{FACTOR_GROUPS.map((group) => {
|
||||
const factors = group.keys.filter(
|
||||
(k) => (cvss_breakdown as unknown as Record<string, string | null>)[k] != null
|
||||
);
|
||||
if (factors.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium">
|
||||
{group.label}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#444] font-medium mr-2">
|
||||
Risk
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{factors.map((key) => {
|
||||
const raw = (cvss_breakdown as unknown as Record<string, string | null>)[key];
|
||||
const level = raw ? (RISK_LEVEL[key]?.[raw] ?? "low") : "low";
|
||||
const label = raw ? (HUMAN_LABELS[key]?.[raw] ?? raw) : "N/A";
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-[12px] text-[#aaa]">{label}</span>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${RISK_BADGE[level]}`}>
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { highlightCode } from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
|
||||
export function MdCodeBlock({
|
||||
className,
|
||||
children,
|
||||
node,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
node?: { data?: { meta?: string }; properties?: { metastring?: string } };
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const raw = String(children).replace(/\n$/, "");
|
||||
const match = /language-(\S+)/.exec(className || "");
|
||||
const isBlock = raw.includes("\n") || match;
|
||||
|
||||
if (!isBlock) {
|
||||
return <code className={`${className || ""} bg-white/8 px-1.5 py-0.5 rounded text-[13px]`}>{children}</code>;
|
||||
}
|
||||
|
||||
const meta = node?.data?.meta || node?.properties?.metastring || "";
|
||||
const titleMatch = /title=["']?([^"'\s}]+)["']?/.exec(meta);
|
||||
const startMatch = /startLineNumber=(\d+)/.exec(meta);
|
||||
const fileName = titleMatch?.[1] || null;
|
||||
const startLine = startMatch ? parseInt(startMatch[1], 10) : 1;
|
||||
const headerLabel = fileName
|
||||
? startMatch
|
||||
? `${fileName}:${startLine}`
|
||||
: fileName
|
||||
: null;
|
||||
|
||||
const highlighted = highlightCode(raw, match?.[1]);
|
||||
|
||||
const lines = highlighted.split("\n");
|
||||
|
||||
const copy = () => {
|
||||
copyToClipboard(raw);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group/code relative rounded-md border border-[#2a2a2a] my-4 text-[#ddd] overflow-hidden">
|
||||
{headerLabel ? (
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">{headerLabel}<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors border-b border-[#2a2a2a]"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={copy}
|
||||
className="absolute top-2 right-2 z-10 p-1 rounded text-[#444] hover:text-white opacity-0 group-hover/code:opacity-100 transition-opacity"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full border-collapse font-mono text-[12px] leading-[22px] [font-variant-ligatures:none]">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i}>
|
||||
<td className="select-none w-[1px] whitespace-nowrap px-4 text-right text-[#555] align-top text-[12px] leading-[22px] border-r border-[#2a2a2a]">
|
||||
{startLine + i}
|
||||
</td>
|
||||
<td
|
||||
className="pl-4 pr-4 whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: line || "\n" }}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// rehype plugin: pass code fence meta string through to code element properties
|
||||
type HastNode = {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
children?: HastNode[];
|
||||
properties?: Record<string, unknown>;
|
||||
data?: { meta?: string };
|
||||
};
|
||||
|
||||
export function rehypeCodeMeta() {
|
||||
return (tree: HastNode) => {
|
||||
const visit = (node: HastNode) => {
|
||||
if (node.type === "element" && node.tagName === "pre" && node.children) {
|
||||
const codeEl = node.children.find(
|
||||
(c) => c.type === "element" && c.tagName === "code"
|
||||
);
|
||||
if (codeEl?.data?.meta) {
|
||||
codeEl.properties = codeEl.properties || {};
|
||||
codeEl.properties.metastring = codeEl.data.meta;
|
||||
}
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach((child) => visit(child));
|
||||
}
|
||||
};
|
||||
visit(tree);
|
||||
};
|
||||
}
|
||||
|
||||
export const mdComponents = {
|
||||
code: MdCodeBlock as React.ComponentType<React.HTMLAttributes<HTMLElement>>,
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { highlightCode } from "@/lib/hljs";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||
import { parseFencedCode } from "@/lib/fenced-code";
|
||||
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
||||
|
||||
interface PocBlockProps {
|
||||
description?: string | null;
|
||||
scriptCode?: string | null;
|
||||
onCopy?: () => void;
|
||||
}
|
||||
|
||||
export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!description && !scriptCode) return null;
|
||||
|
||||
const { language, code } = parseFencedCode(scriptCode);
|
||||
const highlighted = highlightCode(code, language);
|
||||
|
||||
const copy = () => {
|
||||
if (!code) return;
|
||||
copyToClipboard(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white mb-3">Proof of Concept</h2>
|
||||
<div className="space-y-4">
|
||||
{description && (
|
||||
<div className="prose-markdown">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeCodeMeta]}
|
||||
components={mdComponents}
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
{code && (
|
||||
<div className="group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden">
|
||||
<div className="flex items-stretch">
|
||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">PoC Script<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
||||
<div className="flex-1 border-b border-[#2a2a2a]" />
|
||||
<button
|
||||
onClick={copy}
|
||||
className="px-3 py-2 text-[#555] hover:text-white transition-colors flex-shrink-0 border-b border-[#2a2a2a]"
|
||||
aria-label="Copy PoC code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[400px] px-4 py-3">
|
||||
<pre className="font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlighted,
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Clock, CheckCircle2, Ban, History, BellOff, Wrench, GitMerge } from "lucide-react";
|
||||
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
|
||||
import { Vulnerability, VulnerabilityStatus, SEVERITY_COLORS, STATUS_META, isSeverityOverridden } from "@/types/issues";
|
||||
import { formatTimeAgo } from "@/lib/utils";
|
||||
import { getSeverityDot } from "@/lib/vulnerability-utils";
|
||||
import { formatStrixId } from "@/lib/display-number";
|
||||
import { ContentSection } from "@/components/vulnerability/ContentSection";
|
||||
import { CodeDiffBlock } from "@/components/vulnerability/CodeDiffBlock";
|
||||
import { PocBlock } from "@/components/vulnerability/PocBlock";
|
||||
import { IssueSidebar } from "@/components/vulnerability/IssueSidebar";
|
||||
|
||||
function bannerTime(dateString: string | null): string {
|
||||
if (!dateString) return "";
|
||||
const diffInSeconds = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000);
|
||||
if (diffInSeconds < 604800) return ` ${formatTimeAgo(dateString)}`;
|
||||
return ` on ${formatTimeAgo(dateString)}`;
|
||||
}
|
||||
|
||||
const STATUS_BANNER: Record<VulnerabilityStatus, { icon: React.ElementType; label: string; iconColor: string } | null> = {
|
||||
open: null,
|
||||
in_progress: { icon: Clock, label: "Marked as In Progress", iconColor: "text-blue-400" },
|
||||
snoozed: { icon: BellOff, label: "Snoozed", iconColor: "text-purple-400" },
|
||||
fixed: { icon: CheckCircle2, label: "Marked as Fixed", iconColor: "text-emerald-400" },
|
||||
ignored: { icon: Ban, label: "Marked as Ignored", iconColor: "text-[#888]" },
|
||||
};
|
||||
|
||||
type BottomTab = "fix" | "reproduction";
|
||||
|
||||
// Team-workflow actions shown top-right of the finding header; each links out
|
||||
// to sign-up. `requiresCode` actions only appear when the finding has concrete
|
||||
// code locations to act on -- an autofix PR makes no sense for a black-box
|
||||
// finding with no code to change.
|
||||
const WORKFLOW_CTAS: { label: string; slug: string; icon: React.ElementType; requiresCode?: boolean }[] = [
|
||||
{ label: "Auto-fix & open a PR", slug: "autofix", icon: Wrench, requiresCode: true },
|
||||
{ label: "Sync to Jira / Linear", slug: "integrations", icon: GitMerge },
|
||||
];
|
||||
|
||||
interface VulnerabilityDetailProps {
|
||||
vulnerability: Vulnerability;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained finding detail (header + status banners + content grid),
|
||||
* without page chrome. Shared by the public /share/issues page and the local
|
||||
* /results view so both render findings identically.
|
||||
*/
|
||||
export default function VulnerabilityDetail({ vulnerability }: VulnerabilityDetailProps) {
|
||||
const currentMeta = STATUS_META[vulnerability.status];
|
||||
const hasCodeLocations = vulnerability.code_locations && vulnerability.code_locations.length > 0;
|
||||
const hasFix = hasCodeLocations || vulnerability.remediation_steps;
|
||||
const hasReproduction = !!(vulnerability.evidence || vulnerability.assumptions || vulnerability.poc_description || vulnerability.poc_script_code);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<BottomTab>("fix");
|
||||
|
||||
const bottomTabs: { id: BottomTab; label: string; show: boolean }[] = [
|
||||
{ id: "fix", label: "Fix", show: !!hasFix },
|
||||
{ id: "reproduction", label: "Reproduction", show: hasReproduction },
|
||||
];
|
||||
const visibleTabs = bottomTabs.filter((t) => t.show);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header: title/badges on the left, workflow actions top-right. */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2">
|
||||
{vulnerability.display_number && (
|
||||
<span className="text-xs font-mono text-[#555] block mb-1">
|
||||
{formatStrixId(vulnerability.display_number)}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-white">{vulnerability.title}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-medium rounded-full border ${currentMeta.color}`}>
|
||||
{currentMeta.label}
|
||||
</span>
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1 text-sm font-semibold rounded-full border ${SEVERITY_COLORS[vulnerability.severity]}`}
|
||||
title={isSeverityOverridden(vulnerability) ? `Adjusted from ${vulnerability.original_severity}` : undefined}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${getSeverityDot(vulnerability.severity)}`} />
|
||||
<span className="capitalize">
|
||||
{vulnerability.severity}
|
||||
{!isSeverityOverridden(vulnerability) && vulnerability.cvss ? ` ${vulnerability.cvss}` : ""}
|
||||
</span>
|
||||
{isSeverityOverridden(vulnerability) && (
|
||||
<History className="w-3 h-3 opacity-70" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
{vulnerability.cve && (
|
||||
<>
|
||||
<span className="text-[#333]">·</span>
|
||||
<span className="text-sm text-[#666] font-mono">{vulnerability.cve}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 flex-wrap items-center gap-2">
|
||||
{WORKFLOW_CTAS.filter((cta) => !cta.requiresCode || hasCodeLocations).map((cta) => {
|
||||
const Icon = cta.icon;
|
||||
return (
|
||||
<a
|
||||
key={cta.slug}
|
||||
href={ctaUrl(SIGNUP_URL, cta.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackCta(cta.slug, "finding_detail")}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{cta.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
{vulnerability.status !== "open" && (() => {
|
||||
const banner = STATUS_BANNER[vulnerability.status];
|
||||
if (!banner) return null;
|
||||
const BannerIcon = banner.icon;
|
||||
return (
|
||||
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<BannerIcon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${banner.iconColor}`} aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{banner.label}{bannerTime(vulnerability.status_changed_at)}
|
||||
</p>
|
||||
{vulnerability.status_note && (
|
||||
<p className="text-sm text-[#666] italic mt-1">
|
||||
“{vulnerability.status_note}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Severity override banner */}
|
||||
{isSeverityOverridden(vulnerability) && (
|
||||
<div className="rounded-lg px-4 py-3.5 flex gap-3" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<History className="w-5 h-5 flex-shrink-0 mt-0.5 text-orange-400" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
Severity changed manually from{" "}
|
||||
<span className="capitalize">{vulnerability.original_severity}</span>
|
||||
{vulnerability.cvss != null ? ` (${vulnerability.cvss})` : ""} to{" "}
|
||||
<span className="capitalize">{vulnerability.severity}</span>
|
||||
{bannerTime(vulnerability.severity_changed_at)}
|
||||
</p>
|
||||
{vulnerability.severity_override_reason && (
|
||||
<p className="text-sm text-[#666] italic mt-1">
|
||||
“{vulnerability.severity_override_reason}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-8">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0">
|
||||
<div className="space-y-8">
|
||||
<ContentSection title="TL;DR" content={vulnerability.description} />
|
||||
|
||||
{vulnerability.impact && <ContentSection title="Impact" content={vulnerability.impact} />}
|
||||
|
||||
{vulnerability.technical_analysis && (
|
||||
<ContentSection title="Technical Details" content={vulnerability.technical_analysis} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom tabs */}
|
||||
{visibleTabs.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<div className="border-b border-[#2a2a2a]">
|
||||
<nav className="flex gap-6" aria-label="Tabs">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative min-w-[80px] text-center pb-3 text-[16px] font-semibold transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "text-white"
|
||||
: "text-[#666] hover:text-white"
|
||||
}`}
|
||||
aria-current={activeTab === tab.id ? "page" : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 inset-x-0 h-0.5 bg-white rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Fix tab */}
|
||||
{hasFix && (
|
||||
<div className={`pt-6 space-y-6 ${activeTab === "fix" ? "animate-tab-in" : "hidden"}`}>
|
||||
{vulnerability.remediation_steps && (
|
||||
<ContentSection title="How do I fix it?" content={vulnerability.remediation_steps} />
|
||||
)}
|
||||
|
||||
{hasCodeLocations &&
|
||||
vulnerability.code_locations!
|
||||
.filter((loc) => loc.fix_before && loc.fix_after)
|
||||
.map((loc, i) => (
|
||||
<CodeDiffBlock
|
||||
key={`fix-${i}`}
|
||||
file={loc.file}
|
||||
startLine={loc.start_line}
|
||||
endLine={loc.end_line}
|
||||
before={loc.fix_before!}
|
||||
after={loc.fix_after!}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reproduction tab */}
|
||||
{hasReproduction && (
|
||||
<div className={`pt-6 space-y-8 ${activeTab === "reproduction" ? "animate-tab-in" : "hidden"}`}>
|
||||
{vulnerability.assumptions && (
|
||||
<ContentSection title="Assumptions" content={vulnerability.assumptions} />
|
||||
)}
|
||||
|
||||
{vulnerability.evidence && (
|
||||
<ContentSection title="Evidence" content={vulnerability.evidence} />
|
||||
)}
|
||||
|
||||
<PocBlock
|
||||
description={vulnerability.poc_description}
|
||||
scriptCode={vulnerability.poc_script_code}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:border-l lg:border-[#2a2a2a] lg:pl-6">
|
||||
<IssueSidebar
|
||||
vulnerability={vulnerability}
|
||||
statusSlot={
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${currentMeta.color}`}>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${currentMeta.dotColor}`} />
|
||||
{currentMeta.label}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import type { Vulnerability } from "@/types/issues";
|
||||
import {
|
||||
parseRunJson,
|
||||
parseVulnerabilitiesJson,
|
||||
type ParsedRunSummary,
|
||||
} from "@/lib/local-run-parser";
|
||||
|
||||
/**
|
||||
* Data seam for the local viewer: fetches against the local Python server's
|
||||
* JSON endpoints (same origin, relative URLs), producing the in-memory
|
||||
* `LoadedRun` shape the UI renders plus a `finished` flag driving live polling.
|
||||
*
|
||||
* The server serves a live in-progress run and a finished one identically; the
|
||||
* only signal is `run.finished`.
|
||||
*/
|
||||
|
||||
/** A transcript agent as emitted by GET /api/transcript (already parsed). */
|
||||
export interface TranscriptAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Chat/tool event data as emitted by GET /api/transcript. */
|
||||
export interface TranscriptEvent {
|
||||
id: string;
|
||||
type: "chat" | "tool";
|
||||
agent_id: string;
|
||||
timestamp: string;
|
||||
version: number;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Transcript {
|
||||
agents: TranscriptAgent[];
|
||||
events: TranscriptEvent[];
|
||||
}
|
||||
|
||||
export interface LoadedRun {
|
||||
summary: ParsedRunSummary;
|
||||
/** Whole raw run record (for llm_usage, targets_info details, etc.). */
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
vulnerabilities: Vulnerability[];
|
||||
reportMarkdown: string | null;
|
||||
transcript: Transcript;
|
||||
}
|
||||
|
||||
async function getJson(path: string): Promise<unknown> {
|
||||
const res = await fetch(path, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Build a ``?run=<name>`` suffix for run-scoped data endpoints. */
|
||||
function runQuery(runName?: string | null): string {
|
||||
return runName ? `?run=${encodeURIComponent(runName)}` : "";
|
||||
}
|
||||
|
||||
export async function fetchRunSummary(runName?: string | null): Promise<{
|
||||
summary: ParsedRunSummary;
|
||||
raw: Record<string, unknown>;
|
||||
finished: boolean;
|
||||
}> {
|
||||
const raw = (await getJson("/api/run" + runQuery(runName))) as Record<string, unknown>;
|
||||
// parseRunJson tolerates extra keys and takes raw TEXT.
|
||||
const summary = parseRunJson(JSON.stringify(raw));
|
||||
const finished = raw.finished === true;
|
||||
return { summary, raw, finished };
|
||||
}
|
||||
|
||||
export async function fetchVulnerabilities(
|
||||
runId: string | null,
|
||||
runName?: string | null
|
||||
): Promise<Vulnerability[]> {
|
||||
const arr = await getJson("/api/vulnerabilities" + runQuery(runName));
|
||||
return parseVulnerabilitiesJson(JSON.stringify(arr), runId);
|
||||
}
|
||||
|
||||
export async function fetchReportMarkdown(runName?: string | null): Promise<string | null> {
|
||||
const obj = (await getJson("/api/report" + runQuery(runName))) as { markdown?: string };
|
||||
return obj?.markdown ?? null;
|
||||
}
|
||||
|
||||
export async function fetchTranscript(runName?: string | null): Promise<Transcript> {
|
||||
const obj = (await getJson("/api/transcript" + runQuery(runName))) as Partial<Transcript>;
|
||||
return {
|
||||
agents: Array.isArray(obj?.agents) ? obj.agents : [],
|
||||
events: Array.isArray(obj?.events) ? obj.events : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** One-shot fetch of every endpoint (used on mount and on final settle). */
|
||||
export async function fetchAll(runName?: string | null): Promise<LoadedRun> {
|
||||
const { summary, raw, finished } = await fetchRunSummary(runName);
|
||||
const [vulnerabilities, reportMarkdown, transcript] = await Promise.all([
|
||||
fetchVulnerabilities(summary.runId, runName).catch(() => [] as Vulnerability[]),
|
||||
fetchReportMarkdown(runName).catch(() => null),
|
||||
fetchTranscript(runName).catch(() => ({ agents: [], events: [] }) as Transcript),
|
||||
]);
|
||||
return { summary, raw, finished, vulnerabilities, reportMarkdown, transcript };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run history + email auth + report send
|
||||
//
|
||||
// These endpoints back the "Your runs" sidebar section. Auth and report-send
|
||||
// responses carry a meaningful JSON body on non-2xx statuses (an ``error``
|
||||
// code), so they read the body regardless of status rather than throwing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RunSeverityCounts {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface RunListEntry {
|
||||
name: string;
|
||||
target: string | null;
|
||||
scan_mode: string | null;
|
||||
status: string | null;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
finished: boolean;
|
||||
severity_counts: RunSeverityCounts;
|
||||
}
|
||||
|
||||
export interface RunsPayload {
|
||||
locked: boolean;
|
||||
count: number;
|
||||
runs: RunListEntry[];
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
verified: boolean;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export type OtpStartResult = { ok: true } | { ok: false; error: string };
|
||||
export type OtpVerifyResult =
|
||||
| { verified: true; email: string }
|
||||
| { verified: false; error: string };
|
||||
export type SendReportResult =
|
||||
| { ok: true; password: string; filename: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
async function postJson(
|
||||
path: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<{ ok: boolean; status: number; data: Record<string, unknown> }> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const parsed = await res.json();
|
||||
if (parsed && typeof parsed === "object") data = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
/* empty or non-JSON body */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
export async function fetchRuns(): Promise<RunsPayload> {
|
||||
const obj = (await getJson("/api/runs")) as Partial<RunsPayload>;
|
||||
return {
|
||||
locked: obj?.locked ?? true,
|
||||
count: typeof obj?.count === "number" ? obj.count : 0,
|
||||
runs: Array.isArray(obj?.runs) ? (obj.runs as RunListEntry[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
can_steer: boolean;
|
||||
}
|
||||
|
||||
export type SteerResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/** GET /api/capabilities. can_steer is true only inside a live in-TUI scan. */
|
||||
export async function fetchCapabilities(): Promise<Capabilities> {
|
||||
const obj = (await getJson("/api/capabilities")) as Partial<Capabilities>;
|
||||
return { can_steer: obj?.can_steer === true };
|
||||
}
|
||||
|
||||
/** POST /api/agents/steer. Sends a steering instruction to a running agent. */
|
||||
export async function steerAgent(agentId: string, message: string): Promise<SteerResult> {
|
||||
const { ok, data } = await postJson("/api/agents/steer", {
|
||||
agent_id: agentId,
|
||||
message,
|
||||
});
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export type SubmitFeedbackResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* POST /api/feedback. Sends a feedback message plus a work email (no
|
||||
* verification) to the local server, which relays it to Strix.
|
||||
*/
|
||||
export async function submitFeedback(
|
||||
message: string,
|
||||
email: string
|
||||
): Promise<SubmitFeedbackResult> {
|
||||
const { ok, data } = await postJson("/api/feedback", { message, email });
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||
const obj = (await getJson("/api/auth/status")) as Partial<AuthStatus>;
|
||||
return { verified: obj?.verified === true, email: obj?.email ?? null };
|
||||
}
|
||||
|
||||
export async function otpStart(email: string): Promise<OtpStartResult> {
|
||||
const { ok, data } = await postJson("/api/auth/otp/start", { email });
|
||||
if (ok && data.ok === true) return { ok: true };
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
|
||||
export async function otpVerify(email: string, code: string): Promise<OtpVerifyResult> {
|
||||
const { ok, data } = await postJson("/api/auth/otp/verify", { email, code });
|
||||
if (ok && data.verified === true) {
|
||||
return { verified: true, email: String(data.email ?? email) };
|
||||
}
|
||||
return { verified: false, error: String(data.error ?? "invalid_code") };
|
||||
}
|
||||
|
||||
export async function forgetAuth(): Promise<void> {
|
||||
await postJson("/api/auth/forget", {});
|
||||
}
|
||||
|
||||
export async function sendReport(runName?: string | null): Promise<SendReportResult> {
|
||||
const { ok, data } = await postJson("/api/report/send", runName ? { run: runName } : {});
|
||||
if (ok && data.ok === true) {
|
||||
return {
|
||||
ok: true,
|
||||
password: String(data.password ?? ""),
|
||||
filename: String(data.filename ?? "strix-report.pdf"),
|
||||
};
|
||||
}
|
||||
return { ok: false, error: String(data.error ?? "unavailable") };
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-geist-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-geist-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
/* Thin sidebar scrollbar */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Motion vocabulary --------------------- */
|
||||
|
||||
/* Page transition: replayed on every view change via a keyed wrapper. */
|
||||
@keyframes page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-page-in {
|
||||
animation: page-in 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Plain fade. */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 350ms ease-out;
|
||||
}
|
||||
|
||||
/* Staggered card entrance for lists/grids (first four cascade). */
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.animate-card-in {
|
||||
opacity: 0;
|
||||
animation: cardIn 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.animate-card-in:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.animate-card-in:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.animate-card-in:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.animate-card-in:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
/* Shimmer sweep for progress indicators. */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
/* Dialog enter/exit — mirrors shadcn's data-[state]:animate-in/animate-out
|
||||
(fade-in-0/zoom-in-95 in, fade-out-0/zoom-out-95 out) driven off a
|
||||
data-state attribute rather than a transition, so the enter always plays. */
|
||||
@keyframes dialog-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dialog-panel-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
.dialog-overlay[data-state="open"] {
|
||||
animation: dialog-overlay-in 200ms ease;
|
||||
}
|
||||
.dialog-overlay[data-state="closed"] {
|
||||
animation: dialog-overlay-out 200ms ease forwards;
|
||||
}
|
||||
.dialog-panel[data-state="open"] {
|
||||
animation: dialog-panel-in 200ms ease;
|
||||
}
|
||||
.dialog-panel[data-state="closed"] {
|
||||
animation: dialog-panel-out 200ms ease forwards;
|
||||
}
|
||||
|
||||
/* Agent detail modal: fade only (no scale) and faster. Its panel holds the full
|
||||
transcript, and animating a transform on that much DOM janks; fading the
|
||||
overlay (the panel inherits its opacity) stays cheap and snappy. */
|
||||
.agent-modal[data-state="open"] {
|
||||
animation: dialog-overlay-in 140ms ease;
|
||||
}
|
||||
.agent-modal[data-state="closed"] {
|
||||
animation: dialog-overlay-out 140ms ease forwards;
|
||||
}
|
||||
|
||||
/* Tab content transition. */
|
||||
@keyframes tab-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
.animate-tab-in {
|
||||
animation: tab-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Markdown prose styling */
|
||||
.prose-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #999;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.prose-markdown p {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-markdown strong {
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prose-markdown code {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #111;
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #ccc;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.prose-markdown pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-markdown ul,
|
||||
.prose-markdown ol {
|
||||
padding-left: 1.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.prose-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose-markdown li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown li > ul,
|
||||
.prose-markdown li > ol {
|
||||
padding-left: 1.5em;
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.prose-markdown ol + ul {
|
||||
padding-left: 3em;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown h1,
|
||||
.prose-markdown h2,
|
||||
.prose-markdown h3,
|
||||
.prose-markdown h4,
|
||||
.prose-markdown h5,
|
||||
.prose-markdown h6 {
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.prose-markdown a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prose-markdown blockquote {
|
||||
border-left: 3px solid #333;
|
||||
padding-left: 1em;
|
||||
color: #777;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid #222;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
.prose-markdown > table th,
|
||||
.prose-markdown > table td {
|
||||
border: 1px solid #333;
|
||||
padding: 0.4em 0.75em;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.prose-markdown > table th {
|
||||
background: #1a1a1a;
|
||||
color: #ccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// All upsell / sign-up CTAs route anonymous local-viewer users to the public
|
||||
// cloud sign-up. Open in a new tab so the local results stay put.
|
||||
export const SIGNUP_URL = "https://app.strix.ai/api/auth/signup";
|
||||
export const DEMO_URL = "https://strix.ai/demo";
|
||||
export const PRICING_URL = "https://strix.ai/pricing";
|
||||
|
||||
// Attribution params appended to every outbound CTA link so the destination
|
||||
// analytics can see the local viewer drove the click, with utm_content carrying
|
||||
// the CTA slug so we know which one.
|
||||
const CTA_PARAMS =
|
||||
"ref=oss_viewer&utm_source=oss_viewer&utm_medium=local_viewer&utm_campaign=oss_viewer";
|
||||
|
||||
export function ctaUrl(base: string, slug: string): string {
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
return `${base}${sep}${CTA_PARAMS}&utm_content=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
// Best-effort, anonymous beacon. The local server forwards this to PostHog only
|
||||
// if the user has telemetry enabled; it never blocks navigation. Undefined
|
||||
// props are dropped so we only send what is set. NEVER pass PII here (no email,
|
||||
// code, or report content) - the props are limited to anonymous metadata.
|
||||
export function track(event: string, props: Record<string, string | undefined> = {}): void {
|
||||
try {
|
||||
const body: Record<string, string> = { event };
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value !== undefined) body[key] = value;
|
||||
}
|
||||
const payload = JSON.stringify(body);
|
||||
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/event", payload);
|
||||
} else {
|
||||
void fetch("/api/event", { method: "POST", body: payload, keepalive: true });
|
||||
}
|
||||
} catch {
|
||||
/* analytics is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous conversion tracking for a sign-up/upsell click. `surface` records
|
||||
// where the click happened so one CTA slug can be reused across placements.
|
||||
export function trackCta(cta: string, surface?: string): void {
|
||||
track("cta_clicked", { cta, surface });
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Slim, dependency-free extract of strix-app's display-number helper. The full
|
||||
// version queries Supabase to compute org-wide finding numbers; the local viewer
|
||||
// only ever needs the pure formatter, so the supabase-backed functions are
|
||||
// intentionally omitted (a local run has no org context).
|
||||
export function formatStrixId(num: number): string {
|
||||
return `STRIX-${num}`;
|
||||
}
|
||||
|
||||
/** Format an integer with locale thousands separators (e.g. 68339486 -> "68,339,486"). */
|
||||
export function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat("en-US").format(num);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
export interface ParsedFencedCode {
|
||||
language?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const FENCE_RE = /^```([^\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
|
||||
/**
|
||||
* Agent-generated `poc_script_code` is stored wrapped in a markdown code fence
|
||||
* that carries the language, e.g.
|
||||
*
|
||||
* ```python
|
||||
* import requests
|
||||
* ```
|
||||
*
|
||||
* Renderers that show the value as bare code must not display the fence lines
|
||||
* literally. This extracts the inner code and the fence's language tag. Returns
|
||||
* the input unchanged (no language) when it isn't fenced.
|
||||
*/
|
||||
export function parseFencedCode(raw: string | null | undefined): ParsedFencedCode {
|
||||
if (!raw) return { code: "" };
|
||||
const match = FENCE_RE.exec(raw.trim());
|
||||
if (!match) return { code: raw };
|
||||
const info = match[1].trim();
|
||||
const language = info ? info.split(/\s+/)[0] : undefined;
|
||||
return { language: language || undefined, code: match[2] };
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import hljs from "highlight.js/lib/common";
|
||||
import http from "highlight.js/lib/languages/http";
|
||||
import nginx from "highlight.js/lib/languages/nginx";
|
||||
import apache from "highlight.js/lib/languages/apache";
|
||||
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||
import properties from "highlight.js/lib/languages/properties";
|
||||
|
||||
hljs.registerLanguage("http", http);
|
||||
hljs.registerLanguage("nginx", nginx);
|
||||
hljs.registerLanguage("apache", apache);
|
||||
hljs.registerLanguage("dockerfile", dockerfile);
|
||||
hljs.registerLanguage("properties", properties);
|
||||
|
||||
/**
|
||||
* Highlight code, preferring an explicit language when it's recognized,
|
||||
* otherwise auto-detecting. Falls back to Python when auto-detection is
|
||||
* inconclusive, since legacy (unfenced) PoC scripts are Python.
|
||||
*/
|
||||
export function highlightCode(code: string, language?: string | null): string {
|
||||
try {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
||||
}
|
||||
const auto = hljs.highlightAuto(code);
|
||||
if (auto.language) return auto.value;
|
||||
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||
}
|
||||
}
|
||||
|
||||
export default hljs;
|
||||
@@ -1,325 +0,0 @@
|
||||
import type {
|
||||
Vulnerability,
|
||||
VulnerabilitySeverity,
|
||||
VulnerabilityStatus,
|
||||
} from "@/types/issues";
|
||||
|
||||
/**
|
||||
* Pure, dependency-free parsers that turn a Strix CLI local run
|
||||
* (`strix_runs/<run>/{run.json,vulnerabilities.json}`) into the app's own
|
||||
* types, so the /results view can reuse the dashboard's finding components.
|
||||
*
|
||||
* These run entirely client-side against files the user picked from disk —
|
||||
* nothing here uploads or persists anything.
|
||||
*/
|
||||
|
||||
export class RunParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RunParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedRunSummary {
|
||||
runId: string | null;
|
||||
runName: string | null;
|
||||
targets: string[];
|
||||
scanMode: string | null;
|
||||
status: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
durationSeconds: number | null;
|
||||
executiveSummary: string | null;
|
||||
technicalAnalysis: string | null;
|
||||
methodology: string | null;
|
||||
recommendations: string | null;
|
||||
}
|
||||
|
||||
const KNOWN_SEVERITIES: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"];
|
||||
|
||||
function coerceSeverity(raw: unknown): VulnerabilitySeverity {
|
||||
const s = String(raw ?? "").toLowerCase().trim();
|
||||
if ((KNOWN_SEVERITIES as string[]).includes(s)) return s as VulnerabilitySeverity;
|
||||
// The app's severity type has no "info"/"informational" bucket; fold those
|
||||
// (and anything unrecognized) into "low" so the shared UI renders cleanly.
|
||||
return "low";
|
||||
}
|
||||
|
||||
function toIsoTimestamp(raw: unknown): string {
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
// CLI writes e.g. "2025-01-02 03:04:05 UTC".
|
||||
const normalized = raw.trim().replace(" UTC", "Z").replace(" ", "T");
|
||||
const d = new Date(normalized);
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||||
const direct = new Date(raw);
|
||||
if (!Number.isNaN(direct.getTime())) return direct.toISOString();
|
||||
}
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function asStringOrNull(v: unknown): string | null {
|
||||
return typeof v === "string" && v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
function asNumberOrNull(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function parseJson(text: string, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new RunParseError(
|
||||
`${label} isn't valid JSON. Make sure you selected a Strix run directory.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRunJson(text: string): ParsedRunSummary {
|
||||
const data = parseJson(text, "run.json");
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new RunParseError("run.json is not an object.");
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
|
||||
const targets: string[] = [];
|
||||
const targetsInfo = record.targets_info;
|
||||
if (Array.isArray(targetsInfo)) {
|
||||
for (const t of targetsInfo) {
|
||||
if (t && typeof t === "object") {
|
||||
const original = (t as Record<string, unknown>).original;
|
||||
if (typeof original === "string" && original) targets.push(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = asStringOrNull(record.start_time);
|
||||
const endTime = asStringOrNull(record.end_time);
|
||||
let durationSeconds: number | null = null;
|
||||
if (startTime && endTime) {
|
||||
const s = new Date(startTime).getTime();
|
||||
const e = new Date(endTime).getTime();
|
||||
if (!Number.isNaN(s) && !Number.isNaN(e) && e >= s) {
|
||||
durationSeconds = Math.round((e - s) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
let executiveSummary: string | null = null;
|
||||
let technicalAnalysis: string | null = null;
|
||||
let methodology: string | null = null;
|
||||
let recommendations: string | null = null;
|
||||
const scanResults = record.scan_results;
|
||||
if (scanResults && typeof scanResults === "object") {
|
||||
const sr = scanResults as Record<string, unknown>;
|
||||
executiveSummary = asStringOrNull(sr.executive_summary);
|
||||
technicalAnalysis = asStringOrNull(sr.technical_analysis);
|
||||
methodology = asStringOrNull(sr.methodology);
|
||||
recommendations = asStringOrNull(sr.recommendations);
|
||||
}
|
||||
|
||||
return {
|
||||
runId: asStringOrNull(record.run_id),
|
||||
runName: asStringOrNull(record.run_name),
|
||||
targets,
|
||||
scanMode: asStringOrNull(record.scan_mode),
|
||||
status: asStringOrNull(record.status),
|
||||
startTime,
|
||||
endTime,
|
||||
durationSeconds,
|
||||
executiveSummary,
|
||||
technicalAnalysis,
|
||||
methodology,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields on the app's Vulnerability type that a local run never provides. */
|
||||
function emptyVulnerabilityDefaults(): Omit<
|
||||
Vulnerability,
|
||||
| "id"
|
||||
| "title"
|
||||
| "description"
|
||||
| "severity"
|
||||
| "created_at"
|
||||
| "scan_id"
|
||||
| "status"
|
||||
> {
|
||||
return {
|
||||
pr_review_id: null,
|
||||
cve: null,
|
||||
cvss: null,
|
||||
potential_risk_saving: null,
|
||||
risk_saving_description: null,
|
||||
impact: null,
|
||||
endpoint: null,
|
||||
method: null,
|
||||
target: null,
|
||||
technical_analysis: null,
|
||||
poc_description: null,
|
||||
poc_script_code: null,
|
||||
code_diff: null,
|
||||
code_file: null,
|
||||
code_before: null,
|
||||
code_after: null,
|
||||
cwe: null,
|
||||
code_locations: null,
|
||||
remediation_steps: null,
|
||||
fix_pr_body: null,
|
||||
evidence: null,
|
||||
assumptions: null,
|
||||
fix_effort: null,
|
||||
cvss_breakdown: null,
|
||||
status_changed_at: null,
|
||||
status_changed_by: null,
|
||||
status_note: null,
|
||||
snoozed_until: null,
|
||||
reopened_at: null,
|
||||
reopened_by: null,
|
||||
original_severity: null,
|
||||
severity_changed_at: null,
|
||||
severity_changed_by: null,
|
||||
severity_override_reason: null,
|
||||
retest_of_vulnerability_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOneVulnerability(
|
||||
raw: Record<string, unknown>,
|
||||
index: number,
|
||||
runId: string | null
|
||||
): Vulnerability {
|
||||
const cweRaw = raw.cwe;
|
||||
const cwe =
|
||||
typeof cweRaw === "string" && cweRaw.trim()
|
||||
? [cweRaw.trim()]
|
||||
: Array.isArray(cweRaw)
|
||||
? (cweRaw.filter((c) => typeof c === "string" && c) as string[])
|
||||
: null;
|
||||
|
||||
const status: VulnerabilityStatus = "open";
|
||||
|
||||
return {
|
||||
...emptyVulnerabilityDefaults(),
|
||||
id: asStringOrNull(raw.id) ?? `vuln-${index + 1}`,
|
||||
scan_id: runId,
|
||||
title: asStringOrNull(raw.title) ?? "Untitled finding",
|
||||
description: asStringOrNull(raw.description) ?? "",
|
||||
severity: coerceSeverity(raw.severity),
|
||||
status,
|
||||
created_at: toIsoTimestamp(raw.timestamp),
|
||||
cve: asStringOrNull(raw.cve),
|
||||
cvss: asNumberOrNull(raw.cvss),
|
||||
impact: asStringOrNull(raw.impact),
|
||||
endpoint: asStringOrNull(raw.endpoint),
|
||||
method: asStringOrNull(raw.method),
|
||||
target: asStringOrNull(raw.target),
|
||||
technical_analysis: asStringOrNull(raw.technical_analysis),
|
||||
poc_description: asStringOrNull(raw.poc_description),
|
||||
poc_script_code: asStringOrNull(raw.poc_script_code),
|
||||
cwe,
|
||||
code_locations: Array.isArray(raw.code_locations)
|
||||
? (raw.code_locations as Vulnerability["code_locations"])
|
||||
: null,
|
||||
remediation_steps: asStringOrNull(raw.remediation_steps),
|
||||
fix_pr_body: asStringOrNull(raw.fix_pr_body),
|
||||
evidence: asStringOrNull(raw.evidence),
|
||||
assumptions: asStringOrNull(raw.assumptions),
|
||||
fix_effort: (asStringOrNull(raw.fix_effort) as Vulnerability["fix_effort"]) ?? null,
|
||||
cvss_breakdown: (raw.cvss_breakdown as Vulnerability["cvss_breakdown"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseVulnerabilitiesJson(
|
||||
text: string,
|
||||
runId: string | null = null
|
||||
): Vulnerability[] {
|
||||
const data = parseJson(text, "vulnerabilities.json");
|
||||
if (!Array.isArray(data)) {
|
||||
throw new RunParseError("vulnerabilities.json is not a JSON array.");
|
||||
}
|
||||
return data.map((item, i) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new RunParseError(`vulnerabilities.json entry #${i + 1} is not an object.`);
|
||||
}
|
||||
return parseOneVulnerability(item as Record<string, unknown>, i, runId);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParsedAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
parentId: string | null;
|
||||
task: string | null;
|
||||
skills: string[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the agent execution trace from `.state/agents.json` into a pre-ordered
|
||||
* tree (children follow their parent; `depth` drives indentation). Rendered
|
||||
* 100% client-side and never uploaded — traces contain target details, so they
|
||||
* must stay on the user's machine. The heavier `.state/agents.db` (SQLite) is
|
||||
* intentionally ignored; `agents.json` has everything the panel needs.
|
||||
*/
|
||||
export function parseAgentsJson(text: string): ParsedAgent[] {
|
||||
const data = parseJson(text, "agents.json");
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
||||
const record = data as Record<string, unknown>;
|
||||
const statuses = (record.statuses ?? {}) as Record<string, unknown>;
|
||||
const parentOf = (record.parent_of ?? {}) as Record<string, unknown>;
|
||||
const names = (record.names ?? {}) as Record<string, unknown>;
|
||||
const metadata = (record.metadata ?? {}) as Record<string, unknown>;
|
||||
|
||||
const agents = new Map<string, ParsedAgent>();
|
||||
for (const id of Object.keys(statuses)) {
|
||||
const meta = (metadata[id] ?? {}) as Record<string, unknown>;
|
||||
const skillsRaw = meta.skills;
|
||||
agents.set(id, {
|
||||
id,
|
||||
name: asStringOrNull(names[id]) ?? id,
|
||||
status: asStringOrNull(statuses[id]) ?? "unknown",
|
||||
parentId: asStringOrNull(parentOf[id]),
|
||||
task: asStringOrNull(meta.task),
|
||||
skills: Array.isArray(skillsRaw)
|
||||
? skillsRaw.filter((s): s is string => typeof s === "string")
|
||||
: [],
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
if (agents.size === 0) return [];
|
||||
|
||||
const childrenOf = new Map<string | null, string[]>();
|
||||
for (const a of agents.values()) {
|
||||
const key = a.parentId && agents.has(a.parentId) ? a.parentId : null;
|
||||
(childrenOf.get(key) ?? childrenOf.set(key, []).get(key)!).push(a.id);
|
||||
}
|
||||
|
||||
const ordered: ParsedAgent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (id: string, depth: number): void => {
|
||||
const a = agents.get(id);
|
||||
if (!a || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
a.depth = depth;
|
||||
ordered.push(a);
|
||||
for (const childId of childrenOf.get(id) ?? []) visit(childId, depth + 1);
|
||||
};
|
||||
for (const rootId of childrenOf.get(null) ?? []) visit(rootId, 0);
|
||||
// Defensive: include any agents not reachable from a root.
|
||||
for (const a of agents.values()) if (!seen.has(a.id)) ordered.push(a);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function severityCounts(
|
||||
vulns: Vulnerability[]
|
||||
): Record<VulnerabilitySeverity, number> {
|
||||
const counts: Record<VulnerabilitySeverity, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
for (const v of vulns) counts[v.severity] += 1;
|
||||
return counts;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
export interface ParsedTarget {
|
||||
display: string;
|
||||
href: string | null;
|
||||
provider: "github" | "gitlab" | "bitbucket" | null;
|
||||
}
|
||||
|
||||
export function parseTarget(target: string): ParsedTarget {
|
||||
// GitHub URL
|
||||
const ghMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?github\.com\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (ghMatch) {
|
||||
const slug = ghMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://github.com/${slug}`, provider: "github" };
|
||||
}
|
||||
|
||||
// GitLab URL
|
||||
const glMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?gitlab\.com\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (glMatch) {
|
||||
const slug = glMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://gitlab.com/${slug}`, provider: "gitlab" };
|
||||
}
|
||||
|
||||
// Bitbucket URL
|
||||
const bbMatch = target.match(
|
||||
/(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([^\s/]+\/[^\s/]+)/
|
||||
);
|
||||
if (bbMatch) {
|
||||
const slug = bbMatch[1].replace(/\.git$/, "");
|
||||
return { display: slug, href: `https://bitbucket.org/${slug}`, provider: "bitbucket" };
|
||||
}
|
||||
|
||||
// URL with protocol
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
return {
|
||||
display: target.replace(/^https?:\/\/(www\.)?/, ""),
|
||||
href: target,
|
||||
provider: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Bare domain (e.g. "example.com" or "example.com/path")
|
||||
if (/^[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}/.test(target)) {
|
||||
return { display: target, href: `https://${target}`, provider: null };
|
||||
}
|
||||
|
||||
// Not a URL
|
||||
return { display: target, href: null, provider: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A clean, human-readable run title derived from the scan target (e.g.
|
||||
* "arch.co") rather than the raw run dir name ("arch-co_ca3f"). Falls back to
|
||||
* the raw name, then a generic label.
|
||||
*/
|
||||
export function runTitle(target: string | null, fallback: string): string {
|
||||
if (target) return parseTarget(target).display.replace(/\/$/, "");
|
||||
return fallback || "Untitled pentest";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user