mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30c787d690 | ||
|
|
430950570c | ||
|
|
f8bc149872 | ||
|
|
f3a8e82d29 | ||
|
|
e300f85987 | ||
|
|
dfa0e8a566 | ||
|
|
9b20a46eba | ||
|
|
8c8ae97400 | ||
|
|
149151919f | ||
|
|
5cdb720122 | ||
|
|
ae792126dc | ||
|
|
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.
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
|
||||
@@ -161,7 +161,7 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
|
||||
```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:
|
||||
@@ -207,7 +207,7 @@ Daily cron example (03:20, operator crontab):
|
||||
|
||||
## 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.
|
||||
> Upgrading only swaps the program and never touches data: the Postgres volume `pgdata`, `./data` (traffic index, task files, transcripts, etc.), `./keys` (jwt.key, secret.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.
|
||||
|
||||
|
||||
+81
@@ -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
-1
@@ -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}"
|
||||
|
||||
+2
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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';
|
||||
+62
-17
@@ -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).
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -18,7 +18,9 @@ func TestChatUnavailableReasonDistinguishesStates(t *testing.T) {
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
|
||||
// Start from a clean profile table; other tests in the shared DB may have left rows.
|
||||
existing, _ := m.pg.ListProfiles()
|
||||
@@ -68,7 +70,9 @@ func TestResolveChatAgentHonoursConversationProfile(t *testing.T) {
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
|
||||
// Force the global fallback to nil so a non-nil result can ONLY come from the
|
||||
// conversation's own profile — this is exactly the situation the user hit
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ func TestCoreTaskLifecyclePG(t *testing.T) {
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -67,7 +67,9 @@ func TestInheritedActivityDetailAndRelationDeletion(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -50,7 +50,9 @@ SELECT id,'completed','fixed','{}'::jsonb FROM findings WHERE task_id=$1 AND nam
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -144,7 +146,9 @@ VALUES ($1,'xss','Reflected XSS','reflected in search','high','confirmed')`, tas
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+7
-1
@@ -18,8 +18,14 @@ func TestMgmtAPI(t *testing.T) {
|
||||
if m.pg == nil {
|
||||
t.Skip("postgres unavailable — skipping management API test")
|
||||
}
|
||||
// New starts the notifier and task-archive workers on context.Background();
|
||||
// closing the pool stops them from claiming later tests' digest batches
|
||||
// and archive jobs.
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,7 +33,9 @@ INSERT INTO skill_usage(skill,agent_key,task_id,found) VALUES
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -21,7 +21,9 @@ func TestTaskCategoryBatchRoute(t *testing.T) {
|
||||
defer m.Close()
|
||||
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,7 +16,9 @@ func TestWorkerControlRoutes(t *testing.T) {
|
||||
defer m.Close()
|
||||
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, td, td, td)
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -38,7 +38,9 @@ VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -24,7 +24,9 @@ func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) {
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
defer func() { _ = m.pg.DeleteTask(taskID) }()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -66,7 +68,9 @@ func TestConversationBatchDeleteReportsMissing(t *testing.T) {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
first, err := m.pg.CreateConversation("mainagent", "batch-http-first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestTaskOutcomeCard(t *testing.T) {
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
defer func() { _ = m.pg.DeleteTask(taskID) }()
|
||||
// skill_usage has no foreign keys, so DeleteTask leaves these rows behind,
|
||||
// and their misses would outrank TestSkillGapsReport's fixture in later runs.
|
||||
defer func() { _, _ = m.pg.Exec(`DELETE FROM skill_usage WHERE task_id=$1`, taskID) }()
|
||||
|
||||
if _, err := m.pg.Exec(`
|
||||
INSERT INTO findings(task_id,vulnclass,name,severity,status) VALUES
|
||||
@@ -57,7 +60,9 @@ INSERT INTO skill_usage(skill,task_id,found) VALUES ('api-recon',$1,true),('grap
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -20,7 +20,9 @@ func TestTaskTemplateHTTPCRUD(t *testing.T) {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
@@ -90,7 +92,9 @@ func TestConversationPatchReturnsPinState(t *testing.T) {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
defer stopTestServer(t, s, cancel)
|
||||
conversation, err := m.pg.CreateConversation("mainagent", "pin through http", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stopTestServer cancels a test server's context and joins what New started.
|
||||
// New restores every unfinished task in the shared database and runs its
|
||||
// planner, worker and deadline loops, which write under m.dir. Call it with
|
||||
// defer right after New, so it runs before m.Close and the TempDir cleanups.
|
||||
func stopTestServer(t *testing.T, s *Server, cancel context.CancelFunc) {
|
||||
t.Helper()
|
||||
cancel()
|
||||
for _, task := range s.m.List() {
|
||||
s.engine.BeginDelete(task.ID)
|
||||
waitCtx, waitCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := s.waitTaskQuiescent(waitCtx, task.ID); err != nil {
|
||||
t.Errorf("task %s still running at teardown: %v", task.ID, err)
|
||||
}
|
||||
waitCancel()
|
||||
s.engine.StopTask(task.ID)
|
||||
}
|
||||
s.archiveWG.Wait()
|
||||
<-s.side.done
|
||||
}
|
||||
Reference in New Issue
Block a user