Reading the ARTEX source: exit codes that talk to a supervisor script, a hand-rolled query parser that compiles to SQL, and CIDR matching you can test without a database.
· 8 min read

ARTEX in Go: Exit Codes as a Restart Protocol, a Hand-Written Asset DSL, and One-Interface Row Scanning


The main function in ARTEX is one line: os.Exit(run()). The original upstream repository is no longer reachable, so this article links to a public mirror at the snapshot available when the PR was raised. The comment above main explains why the split matters:

main only maps run’s result onto the process exit code. The exit code is part of the update protocol — the supervising start script reads it to decide whether to relaunch us (see selfupdate.ExitRestart) — so the body has to live in a function that can return rather than os.Exit past its own defers.

ARTEX is an autonomous penetration-testing system built around LLM agents. Its Go backend contains several useful boundary decisions: exit codes used as a channel to a supervisor, a search language parsed by hand, blocklist matching separated from storage, and explicit handling of nullable database fields.

Exit codes as a restart protocol

Read the ordering in cmd/artex/main.go. Flags parse, the banner prints, log capture starts, and then selfupdate.Bootstrap() runs, before any store opens and before anything binds a port. Its return value gets checked against a sentinel:

action, upState := selfupdate.Bootstrap()
server.SetBootUpdateState(upState)
if action == selfupdate.Restart {
    return selfupdate.ExitRestart
}

The binary can pick up a staged build, decide it should be relaunched as that new binary, and exit with a code the wrapper script recognizes. The comment spells out the reasoning: no sense opening SQLite stores or binding :8787 when the process is about to die three lines later.

The normal return paths depend on run returning an int. os.Exit skips deferred functions, so returning the status lets defer mgr.Close() and defer stop() run before main exits. This is not an absolute guarantee in this snapshot: run still contains log.Fatalf calls, which also terminate through os.Exit and skip active defers. The pattern works only when error paths return instead of calling an exit function directly:

func main() {
    os.Exit(run())
}

func run() int {
    f, err := os.Open("config.json")
    if err != nil {
        fmt.Fprintln(os.Stderr, "open config:", err)
        return 1
    }
    defer f.Close() // actually runs

    // ... real work ...
    return 0
}

For more on defer ordering and the traps around it, see How to defer in Golang like a pro. On the CLI side, Agent Tincan’s Cobra exit codes applies the same thinking inside a Cobra app.

The same function has a second move I liked more. Once the stores open, ARTEX arms a timer:

settle := time.AfterFunc(selfupdate.SettleDelay, selfupdate.Settle)
defer settle.Stop()

time.AfterFunc fires selfupdate.Settle on its own goroutine after the delay. Per the source comment, staying alive that long is treated as evidence that the freshly swapped build works, so the upgrade marker is removed. A build that exits before SettleDelay leaves the marker in place; later boots increment the attempt count and eventually trigger rollback.

Shutdown layers on top of signal.NotifyContext:

sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, shutdown := shutdownContext(sigCtx)
defer shutdown(agent.AbortShutdown)

The shutdown function takes a cause instead of being a bare context.CancelFunc. In this snapshot both an OS signal and an update-triggered restart use the same agent.AbortShutdown cause; the distinction being preserved is therefore an intentional shutdown versus an unlabelled context.Canceled, not Ctrl-C versus every other shutdown path. The helper deliberately avoids deriving directly from the signal context so the plain parent cancellation cannot win before context.WithCancelCause records that cause.

A search DSL that compiles to SQL, parsed by hand

db/asset_dsl.go implements an asset search language. Operators type domain=example.com AND (port=443 OR port=8443) and the package turns that into SQL. It’s a recursive descent parser, written out longhand, with no dependencies.

The data shapes stay small. Expr is a single leaf clause (Field, Op, Value), and an empty Field means bare full-text search. astNode carries a kind string of "and", "or", or "leaf", plus children and an *Expr that only gets set on leaves. Constructors andNode, orNode, and leafNode keep the build-up readable instead of a wall of struct literals.

The grammar lives in a comment above the parser:

expr     = or_expr
or_expr  = and_expr (OR and_expr)*
and_expr = atom    (AND atom)*
atom     = FIELD | BARE | '(' expr ')'

parseOr calls parseAnd, which calls parseAtom. Putting the looser operator at the top of the call chain is what gives AND tighter binding, and it’s the step people reliably botch when hand-rolling a parser for the first time. The parser struct is tokens []tok and pos int, with peek and consume. peek hands back a synthetic tkEOF token once pos runs off the end, so not one caller does a bounds check.

The tokenizer is a byte loop. Spaces get skipped via unicode.IsSpace, ( and ) are handled directly, tryParseFieldExpr gets a shot at field=value forms, and anything left is read as a word. Keyword matching runs through strings.ToUpper(word), so and, And, and AND all land; everything else becomes a bare-text leaf.

Now the part that matters for security. asset_dsl.go keeps three maps — knownStringFields, knownArrayFields, knownNumericFields — mapping a DSL name to a real SQL column. title becomes page_title. tech, technology, and technologies all collapse to technologies. status becomes status_code. isKnownField lowercases the input and checks all three maps plus company_id and task_id.

That lookup is the entire defense, and it’s enough. User input never becomes a column name; it only picks one out of a fixed set, and values go in as placeholders. There’s a comment explaining that "type" is left out of the maps on purpose because it arrives as a separate query parameter, which is the sort of note that stops some future contributor from helpfully “fixing” the gap and punching a hole in the whitelist.

Copy this shape if you’re building anything that lets users name fields: a map[string]string from external name to internal identifier, plus an isKnown* helper that fails closed. String concatenation into a column position is how you get a CVE.

Splitting rule storage from rule matching

ARTEX has an asset blocklist, and it lives in two files on purpose.

db/asset_intercept.go stores rules. The comment on AssetInterceptRule says that matching and enforcement live elsewhere. A rule has a Kind (exact_domain, fuzzy_ip, cidr, and others), a Pattern, and a Builtin flag. The create and full-update methods use PostgreSQL RETURNING, so those callers get the complete row back without a follow-up SELECT. Delete and enable/disable operations use Exec because they return only an error.

The scan helper in that file is the prettiest line of Go in the repo:

func scanAssetInterceptRule(row interface{ Scan(...any) error }) (AssetInterceptRule, error) {

An inline anonymous interface. *sql.Row from QueryRow and *sql.Rows from Query both have Scan(...any) error, so a single helper covers ListAssetInterceptRules, CreateAssetInterceptRule, and UpdateAssetInterceptRule. Nothing new to export, and field ordering is written once rather than copy-pasted into five functions where one of them will eventually drift. The trick generalizes to any two stdlib types that share a method you care about:

// Works for *sql.Row and *sql.Rows alike.
func scanUser(row interface{ Scan(...any) error }) (User, error) {
    var u User
    err := row.Scan(&u.ID, &u.Email, &u.CreatedAt)
    return u, err
}

Enforcement sits in db/asset_intercept_match.go, and those functions are pure. matchOne(r AssetInterceptRule, domains, ips, urls []string) and MatchAssetInterceptRules(rules []AssetInterceptRule, ...) accept rules as arguments instead of reaching for a *DB. The whole blocklist is testable with struct literals and no database fixture, which is the strongest practical argument I know for keeping query methods and decision logic in separate files.

Each rule kind uses the stdlib function that actually fits. exact_domain goes through strings.EqualFold, since DNS is case-insensitive. The fuzzy kinds lowercase both sides and run strings.Contains. The cidr kind parses its pattern with net.ParseCIDR and tests candidates with ipnet.Contains(net.ParseIP(ip)), bailing out with no match if the pattern won’t parse.

Here’s the sharp edge, and you should know about it before you copy the design: fuzzy_ip is a substring test against the IP string, so the pattern 10.1 matches 110.1.2.3. That’s a blocklist quietly covering more than its author meant. The cidr kind exists for exactly this reason, and it’s the one to use whenever you mean a network range rather than a text fragment.

(*Asset).interceptCandidates is worth lifting too. It gathers Domain, RootDomain, BoundDomains, IP, and URL, then parses the URL and sorts the hostname into the right bucket:

if u, err := url.Parse(a.URL); err == nil {
    if h := u.Hostname(); h != "" {
        if net.ParseIP(h) != nil {
            // treat as IP candidate
        } else {
            // treat as domain candidate
        }
    }
}

url.Hostname() strips the port and the brackets around IPv6 literals, and a non-nil net.ParseIP is the cheapest answer to “literal address or name?” The payoff, per the comment: a service asset carrying nothing but a URL still gets caught by a domain or IP rule instead of sliding through because it had the wrong field populated.

Nullable columns in the Asset struct

One more thing, from db/assets.go. The Asset struct uses pointers for every field that can legitimately be absent: Port *int, StatusCode *int, ContentLength *int64, CompanyID *int64, TaskSourceNodeID *int64, each tagged json:"...,omitempty".

The pairing is the point. With a plain int, port 0 and “no port recorded” are indistinguishable, and omitempty would quietly swallow a genuine zero status code. With *int, nil means unknown, *p == 0 means zero, and omitempty only hides the first. Slice fields like Technologies []string and OpenPorts []map[string]any use omitempty without a pointer. That omits both nil and zero-length slices; if an API needs to distinguish [] from an absent key, this representation does not preserve that distinction.

In an asset table where a row might be a subdomain, an IP, a mobile app, or an HTTP service, and most columns are empty for any given type, this choice decides whether your JSON API reads cleanly or ships "port": 0 on every mobile app record until a frontend engineer files a bug. If you want another Go service that’s careful at the HTTP/JSON boundary, how uptimy/agent builds its HTTP API covers the net/http half of the same problem.

The common thread is explicit boundary handling: process and supervisor, user text and SQL identifiers, stored rules and matching, database NULL and JSON. The useful parts are small, but their comments make the intended behavior and its limits visible.