Compare commits

...
Author SHA1 Message Date
BaaRai 0116786f02 Fix review: dalfox --rate-limit/--workers, drop passive subfinder -t
Dalfox v3.2.4 (Rust) has no -w and exposes a global --rate-limit plus --workers (docs/content/reference/cli.md). Subfinder -t only bounds concurrent DNS lookups in active mode, so the passive line is removed; the generic fallback covers it.
2026-11-13 12:17:51 +01:00
BaaRai 4595274fd6 Agent: low-and-slow scanner guidance under a noise profile (off by default)
When ARTEX_NOISE_PROFILE is set, the planner, worker, main agent and chat system prompts gain a code-owned block with the profile per-host limits and exact rate/delay flags for the scanners installed in the image. With no profile the block is empty, so every prompt is byte-for-byte unchanged.

README documents the block.
2026-11-13 12:05:14 +01:00
BaaRai 0edf3f0fa7 Merge pull request #211 from BaaRai/feat/always-allow-argv
Intercept: always allow a pending Bash prefix (argv rule, code-owned denylist)
2026-11-13 11:36:25 +01:00
BaaRai 62e65f1235 Intercept: always allow a pending Bash prefix (argv allow rule, code-owned denylist)
POST /api/intercept/pending/{id}/always-allow derives an argv allow rule from the stored pending row (never from the body), at priority -1000, reusing an identical enabled rule, and leaves the request pending. A code-owned denylist of interpreters, launchers, package managers, orchestration and destructive programs refuses such rules at creation, in the manual rule form, and neutralises argv allow rules at matching; argv deny and ask rules still match.
2026-11-13 11:24:25 +01:00
BaaRai e2fd4908f7 Merge pull request #210 from BaaRai/feat/argv-prefix-rules
Intercept: argv prefix rules (match_type=argv)
2026-11-13 10:33:04 +01:00
17 changed files with 651 additions and 7 deletions
+2
View File
@@ -274,6 +274,8 @@ Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama)
**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.
**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.
**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.
**Common flags**: `./start.sh -addr :8787 -proxy :8788` (`-addr` frontend+API, `-proxy` traffic recording proxy). The startup script passes arguments through to `artex` verbatim.
+1 -1
View File
@@ -89,7 +89,7 @@ func chatWorkDirSpec(workDir string) string {
func chatSystem(agentKey, dataDir, workDir string) string {
body := renderSystem(agentKey, DefaultAssistantPrompt, chatVars{DataDir: dataDir, Now: nowStr()})
body += untrustedContentTail(body)
return body + chatWorkDirSpec(workDir)
return body + chatWorkDirSpec(workDir) + noiseProfileBlock()
}
// chatVars carries the runtime variables a custom agent's prompt may reference.
+1 -1
View File
@@ -103,7 +103,7 @@ Do not fabricate findings; answer only from real data returned by tools.`
func mainAgentSystem(goal, dataDir, workDir string) string {
body := renderSystem("mainagent", mainAgentDefaultTmpl, MainVars{Goal: goal, DataDir: dataDir, Now: nowStr()})
return body + untrustedContentTail(body) + artifactSpec(workDir)
return body + untrustedContentTail(body) + artifactSpec(workDir) + noiseProfileBlock()
}
// Chat handles one human message and returns the assistant reply. emit, if
+68
View File
@@ -0,0 +1,68 @@
package agent
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/Autumn-27/artex/egress"
)
// noiseProfileHeader starts the code-owned noise block. The block is appended to
// every agent prompt that can launch Bash; with no profile it is empty, so the
// prompts stay byte-for-byte unchanged by default.
const noiseProfileHeader = "[Noise profile: "
// noiseProfileBlock returns the low-and-slow guidance for the active noise
// profile (ARTEX_NOISE_PROFILE), or "" when the profile is off. The text is
// code-owned and carries no target data.
func noiseProfileBlock() string { return renderNoiseProfile(egress.ProfileFromEnv()) }
// renderNoiseProfile renders the block for one profile. It is pure: tests cover
// every profile without touching the environment.
func renderNoiseProfile(p egress.Profile) string {
if p.Name == "" || p.Name == "off" || p.RPS <= 0 {
return ""
}
rps := int(p.RPS + 0.5)
if rps < 1 {
rps = 1
}
conc := p.MaxPerHost
if conc < 1 {
conc = 1
}
// The profile's delay between requests is 1/RPS (500 ms, 200 ms, 50 ms).
delayMS := int((time.Second / time.Duration(rps)) / time.Millisecond)
delaySec := strconv.FormatFloat(1/float64(rps), 'f', -1, 64)
inFlight := fmt.Sprintf("%d requests", conc)
if conc == 1 {
inFlight = "1 request"
}
// nmap and masscan count packets, not requests; the profile's RPS is used as a
// deliberately conservative packet cap.
timing := map[string]string{"quiet": "-T2", "balanced": "-T3", "aggressive": "-T4"}[p.Name]
if timing == "" {
timing = "-T3"
}
retries := "2"
if p.Name == "aggressive" {
retries = "1"
}
var b strings.Builder
fmt.Fprintf(&b, "%s%s. Operator setting, not a target fact.]\n", noiseProfileHeader, p.Name)
b.WriteString("Scan at a reduced rate. When you run one of these scanners, use the flags exactly:\n")
fmt.Fprintf(&b, "- nmap: %s --max-rate %d --max-retries %s\n", timing, rps, retries)
fmt.Fprintf(&b, "- nuclei: -rate-limit %d -c %d\n", rps, conc)
fmt.Fprintf(&b, "- httpx: -rl %d -t %d\n", rps, conc)
fmt.Fprintf(&b, "- katana: -rl %d -c %d\n", rps, conc)
fmt.Fprintf(&b, "- ffuf: -rate %d -t %d\n", rps, conc)
fmt.Fprintf(&b, "- dalfox scan: --rate-limit %d --workers %d\n", rps, conc)
fmt.Fprintf(&b, "- sqlmap: --delay=%s --threads=1\n", delaySec)
fmt.Fprintf(&b, "For any other tool, stay at or below %d requests per second per host, at most %s in flight, and at least %d ms between requests, using the tool's own rate or delay flag. Keep bbot within the same limits with its own rate settings, and do not enable active modules the program policy does not allow.\n", rps, inFlight, delayMS)
b.WriteString("Prefer passive and read-only checks; test one host at a time; do not run two scanners against the same host in parallel; on 429 or 503 stop and back off; never raise the rate to meet a deadline.\n")
b.WriteString("ARTEX paces its own WebFetch, http custom tool and enrich traffic with this profile, but commands launched through Bash are not paced: these flags are the only brake.\n")
return b.String()
}
+76
View File
@@ -0,0 +1,76 @@
package agent
import (
"fmt"
"strings"
"testing"
"github.com/Autumn-27/artex/egress"
)
func TestRenderNoiseProfileOff(t *testing.T) {
for _, p := range []egress.Profile{{}, {Name: "off"}, {Name: "off", RPS: 5}} {
if got := renderNoiseProfile(p); got != "" {
t.Errorf("renderNoiseProfile(%+v) = %q, want empty", p, got)
}
}
}
func TestRenderNoiseProfileTable(t *testing.T) {
for _, c := range []struct {
name string
rps int
conc int
delay int
timing string
retries string
sql string
}{
{"quiet", 2, 1, 500, "-T2", "2", "0.5"},
{"balanced", 5, 2, 200, "-T3", "2", "0.2"},
{"aggressive", 20, 4, 50, "-T4", "1", "0.05"},
} {
got := renderNoiseProfile(egress.Profiles[c.name])
for _, want := range []string{
noiseProfileHeader + c.name,
fmt.Sprintf("- nmap: %s --max-rate %d --max-retries %s", c.timing, c.rps, c.retries),
fmt.Sprintf("- nuclei: -rate-limit %d -c %d", c.rps, c.conc),
fmt.Sprintf("- httpx: -rl %d -t %d", c.rps, c.conc),
fmt.Sprintf("- katana: -rl %d -c %d", c.rps, c.conc),
fmt.Sprintf("- ffuf: -rate %d -t %d", c.rps, c.conc),
fmt.Sprintf("- dalfox scan: --rate-limit %d --workers %d", c.rps, c.conc),
fmt.Sprintf("- sqlmap: --delay=%s --threads=1", c.sql),
fmt.Sprintf("at least %d ms between requests", c.delay),
"commands launched through Bash are not paced",
} {
if !strings.Contains(got, want) {
t.Errorf("%s: missing %q in:\n%s", c.name, want, got)
}
}
}
}
// The default must stay identical: with no profile the block is empty and none of
// the four prompts that can launch Bash gains any text.
func TestPromptBuildersNoiseBlock(t *testing.T) {
builders := map[string]func() string{
"planner": func() string { return plannerSystem("goal", "/data", "/work") },
"worker": func() string { return workerSystem("", "", "/data", "/run") },
"mainagent": func() string { return mainAgentSystem("goal", "/data", "/work") },
"chat": func() string { return chatSystem("auto", "/data", "/work") },
}
for _, env := range []string{"", "off"} {
t.Setenv("ARTEX_NOISE_PROFILE", env)
for name, build := range builders {
if got := build(); strings.Contains(got, noiseProfileHeader) {
t.Errorf("%s with profile %q contains the noise block", name, env)
}
}
}
t.Setenv("ARTEX_NOISE_PROFILE", "quiet")
for name, build := range builders {
if got := build(); !strings.Contains(got, noiseProfileHeader+"quiet") {
t.Errorf("%s with quiet is missing the noise block", name)
}
}
}
+1 -1
View File
@@ -448,7 +448,7 @@ No duplicates, no padding; but when the goal is unmet and there are uncovered, d
func plannerSystem(goal, dataDir, workDir string) string {
body := renderSystem("planner", plannerDefaultTmpl, PlannerVars{Goal: goal, DataDir: dataDir, Now: nowStr()})
return body + untrustedDataRules + artifactSpec(workDir)
return body + untrustedDataRules + artifactSpec(workDir) + noiseProfileBlock()
}
// Plan runs one planning round. emit, if non-nil, receives the planner's execution
+1 -1
View File
@@ -321,7 +321,7 @@ func workerSystem(proxyAddr, caCert, dataDir, runDir string) string {
// caCert is present only when the recording MITM is on, which is exactly when
// the traffic_* tools are registered — so it gates the traffic-tool note.
// Optional finding guidance is added for every role after tool resolution.
return body + untrustedContentTail(body) + workerTrafficBlock(caCert != "") + workerArtifactSpec(runDir)
return body + untrustedContentTail(body) + workerTrafficBlock(caCert != "") + workerArtifactSpec(runDir) + noiseProfileBlock()
}
// renderIntentTask formats the claimed intent for the worker's launch USER message:
+103
View File
@@ -0,0 +1,103 @@
package intercept
import (
"encoding/json"
"errors"
"fmt"
"path"
"strings"
)
// alwaysAllowDenylist holds the programs an argv allow rule may never name:
// with flags unconstrained, an allow rule for one of them runs arbitrary code
// or does unbounded damage. It is code-owned on purpose (no setting, no env
// var). Names are compared after argvProgramName: basename, lower case, no
// version suffix (python3.11 -> python, mkfs.ext4 -> mkfs).
var alwaysAllowDenylist = func() map[string]bool {
m := map[string]bool{}
for _, group := range []string{
// interpreters and shells
"sh bash zsh dash ksh fish csh tcsh ash mksh rbash pwsh powershell python pypy ipython perl ruby irb " +
"node deno bun php lua luajit rscript tclsh wish expect awk gawk mawk nawk sed jshell osascript",
// launchers: run their arguments (or a flag's value) as a command
"env command builtin exec eval source . nohup timeout nice ionice stdbuf setsid chroot unshare nsenter " +
"systemd-run su sudo doas pkexec runuser setpriv sg watch busybox toybox xargs parallel make gmake " +
"mapfile trap script find ssh scp sftp rsync socat ncat nc netcat flock strace ltrace gdb " +
"taskset chrt unbuffer rlwrap firejail bwrap proot proxychains torsocks tar zip vim vi nvim emacs less more man",
// package managers and build tools: run package scripts and hooks
"npm npx pnpm yarn bunx corepack pip pipx uv uvx poetry conda composer gem bundle bundler rake cargo go " +
"mvn gradle dotnet apt apt-get dpkg dnf yum rpm pacman apk brew snap flatpak",
// orchestration: run images, playbooks and plans
"docker docker-compose podman nerdctl kubectl helm ansible ansible-playbook terraform pulumi vagrant",
// destructive
"rm dd mkfs mkswap wipefs fdisk sfdisk parted shred truncate chmod chown kill pkill killall systemctl service " +
"iptables ip6tables nft ufw mount umount swapoff shutdown reboot poweroff halt init telinit crontab " +
"insmod rmmod modprobe",
} {
for _, name := range strings.Fields(group) {
m[name] = true
}
}
return m
}()
// argvProgramName normalises a program token for the denylist: the basename,
// in lower case, without a trailing version (python3.11, php8.2, perl5, pip3)
// and with every mkfs.* variant reduced to mkfs.
func argvProgramName(tok string) string {
name := strings.ToLower(path.Base(tok))
if strings.HasPrefix(name, "mkfs.") {
return "mkfs"
}
// Strip a trailing run of digits and dots, but only after a letter, so a
// name made only of digits (or ".") is kept as is.
end := len(name)
for end > 0 && (name[end-1] == '.' || (name[end-1] >= '0' && name[end-1] <= '9')) {
end--
}
if end > 0 && end < len(name) {
return name[:end]
}
return name
}
// ArgvAllowDenied reports whether an argv allow rule whose first token is
// program is refused by the denylist.
func ArgvAllowDenied(program string) bool {
return alwaysAllowDenylist[argvProgramName(program)]
}
// CheckArgvAllow returns the error shown when an allow rule (manual or from
// "always allow") names a denylisted program, nil otherwise.
func CheckArgvAllow(tokens []string) error {
if len(tokens) > 0 && ArgvAllowDenied(tokens[0]) {
return fmt.Errorf("program %q cannot be always allowed: it can run arbitrary code or cause unbounded damage whatever its arguments", tokens[0])
}
return nil
}
// AlwaysAllowPrefix derives the argv pattern an "always allow" of a pending
// call would create: Bash only, one simple command, a non-empty prefix whose
// program is not denylisted.
func AlwaysAllowPrefix(toolName string, input []byte) ([]string, error) {
if toolName != "Bash" {
return nil, errors.New("always allow applies to Bash commands only")
}
var in struct {
Command string `json:"command"`
}
if err := json.Unmarshal(input, &in); err != nil {
return nil, fmt.Errorf("the tool input is not a Bash call: %w", err)
}
prefix, err := ArgvPrefix(in.Command)
if err != nil {
return nil, fmt.Errorf("no prefix can be derived from this command: %w", err)
}
if len(prefix) == 0 {
return nil, errors.New("no prefix can be derived from this command")
}
if err := CheckArgvAllow(prefix); err != nil {
return nil, err
}
return prefix, nil
}
+95
View File
@@ -0,0 +1,95 @@
package intercept
import (
"encoding/json"
"slices"
"testing"
"github.com/Autumn-27/artex/db"
)
func TestArgvAllowDenylist(t *testing.T) {
for _, prog := range []string{
"python", "python3", "python3.11", "/usr/bin/python3.11", "php8.2", "ruby3.2", "perl5", "pip3",
"env", "/usr/bin/env", "bash", "/bin/sh", "find", "rm", "mkfs", "mkfs.ext4", "/sbin/mkfs.xfs",
"docker", "kubectl", "npm", "sudo", "xargs", "Rscript", ".",
} {
if !ArgvAllowDenied(prog) {
t.Errorf("%q is not denied", prog)
}
}
for _, prog := range []string{"nmap", "git", "/usr/bin/git", "curl", "ls", "gh", "x", "7z", "pythonic"} {
if ArgvAllowDenied(prog) {
t.Errorf("%q is denied, want allowable", prog)
}
}
}
func TestAlwaysAllowPrefix(t *testing.T) {
bash := func(cmd string) []byte {
b, _ := json.Marshal(map[string]string{"command": cmd})
return b
}
if got, err := AlwaysAllowPrefix("Bash", bash("git push --force origin main")); err != nil || !slices.Equal(got, []string{"git", "push"}) {
t.Fatalf("git push: %q %v", got, err)
}
if got, err := AlwaysAllowPrefix("Bash", bash("nmap -p 80 10.0.0.1")); err != nil || !slices.Equal(got, []string{"nmap"}) {
t.Fatalf("nmap: %q %v", got, err)
}
for name, c := range map[string]struct {
tool string
input []byte
}{
"not Bash": {"shell_open", bash("git push")},
"compound": {"Bash", bash("git push; id")},
"substitution": {"Bash", bash("git push $(id)")},
"redirection": {"Bash", bash("git push > out")},
"empty": {"Bash", bash("")},
"no command": {"Bash", []byte(`{}`)},
"not json": {"Bash", []byte(`not json`)},
"python": {"Bash", bash("python3.11 -c 'import os'")},
"path to python": {"Bash", bash("/usr/bin/python3 x.py")},
"find": {"Bash", bash("find . -exec sh -c id ;")},
"rm": {"Bash", bash("rm -rf /")},
"docker": {"Bash", bash("docker run --privileged x")},
} {
if got, err := AlwaysAllowPrefix(c.tool, c.input); err == nil {
t.Errorf("%s: prefix %q, want a refusal", name, got)
}
}
}
// The denylist neutralises argv allow rules at matching time (a rule created
// before the denylist, or written straight into the database), and only them:
// an argv deny or ask rule for the same program still matches.
func TestArgvDenylistAtMatching(t *testing.T) {
ask := db.InterceptRule{ID: 99, Name: "ask every Bash call", Enabled: true, Priority: -2000, MatchTarget: "tool_name",
MatchType: "string", Pattern: "Bash", Action: "ask"}
argv := func(id int64, pattern, action string) db.InterceptRule {
return db.InterceptRule{ID: id, Name: "argv " + action + " " + pattern, Enabled: true, Priority: -1000,
MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: action}
}
ic := New(nil)
ic.SetRulesForTest([]db.InterceptRule{
argv(1, `["python"]`, "allow"), argv(2, `["python3.11"]`, "allow"), argv(3, `["find"]`, "allow"),
argv(4, `["/usr/bin/env"]`, "allow"), argv(5, `["nmap"]`, "allow"), ask,
})
for _, cmd := range []string{"python x.py", "python3.11 -c 'import os'", "find . -name x", "/usr/bin/env"} {
input, _ := json.Marshal(map[string]string{"command": cmd})
dec, ok := ic.Match("Bash", input)
if !ok || dec.Action != "ask" || dec.RuleID != 99 {
t.Errorf("%q: %+v %v, want the fallback ask rule", cmd, dec, ok)
}
}
if dec, ok := ic.Match("Bash", []byte(`{"command":"nmap -p 80 x"}`)); !ok || dec.Action != "allow" || dec.RuleID != 5 {
t.Errorf("nmap: %+v %v, want the argv allow", dec, ok)
}
ic.SetRulesForTest([]db.InterceptRule{argv(1, `["python"]`, "allow"), argv(6, `["python"]`, "deny"), argv(7, `["find"]`, "ask")})
if dec, ok := ic.Match("Bash", []byte(`{"command":"python x.py"}`)); !ok || dec.Action != "deny" || dec.RuleID != 6 {
t.Errorf("python: %+v %v, want the argv deny", dec, ok)
}
if dec, ok := ic.Match("Bash", []byte(`{"command":"find . -delete"}`)); !ok || dec.Action != "ask" || dec.RuleID != 7 {
t.Errorf("find: %+v %v, want the argv ask", dec, ok)
}
}
+5
View File
@@ -753,6 +753,11 @@ func (i *Interceptor) SetRulesForTest(rules []db.InterceptRule) {
func ruleMatches(r compiledRule, toolName string, input []byte) bool {
if r.MatchType == "argv" {
// Bash only, on the parsed command; match_target is always tool_input.
// The denylist neutralises allow rules only: an argv deny or ask rule
// for a denylisted program still matches.
if r.Action == "allow" && CheckArgvAllow(r.argv) != nil {
return false
}
return r.MatchTarget == "tool_input" && argvRuleMatches(r.argv, toolName, input)
}
var subject string
+81 -2
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
@@ -413,6 +414,67 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true})
}
// 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
// interceptAlwaysAllow creates an argv allow rule from a pending Bash request.
// The request body is never read: the command comes from the stored row, so a
// caller cannot choose what gets allowed. The request itself stays pending.
func (s *Server) interceptAlwaysAllow(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok {
writeErr(w, 400, "bad pending id")
return
}
p, err := pg.GetInterceptPending(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if p == nil {
writeErr(w, 404, "not found")
return
}
if p.Status != "pending" {
writeErr(w, 409, "the request is no longer pending")
return
}
prefix, err := intercept.AlwaysAllowPrefix(p.ToolName, p.ToolInput)
if err != nil {
writeErr(w, 400, err.Error())
return
}
pattern, _ := json.Marshal(prefix)
rules, err := pg.ListInterceptRules()
if err != nil {
writeErr(w, 500, err.Error())
return
}
for _, rule := range rules {
if rule.MatchType != "argv" || rule.Action != "allow" || !rule.Enabled || rule.MatchTarget != "tool_input" {
continue
}
if tokens, err := intercept.ParseArgvPattern(rule.Pattern); err == nil && slices.Equal(tokens, prefix) {
writeJSON(w, 200, map[string]any{"rule": rule, "created": false})
return
}
}
// Timeout fields take the rule form's defaults.
rule, err := pg.CreateInterceptRule("always-allow: "+strings.Join(prefix, " "), "tool_input", "argv", string(pattern),
"allow", "", alwaysAllowPriority, true, true, 60, "deny")
if err != nil {
writeErr(w, 500, err.Error())
return
}
s.m.interceptor.Invalidate()
writeJSON(w, 200, map[string]any{"rule": rule, "created": true})
}
// --- target pins (hosts a human approved for one task or one conversation) ---
// taskPinScope resolves {id} to the pin scope of an existing task.
@@ -667,9 +729,15 @@ func validateInterceptRuleReq(req interceptRuleReq) error {
if req.MatchTarget != "tool_input" {
return fmt.Errorf("an argv rule matches tool_input (the Bash command)")
}
if _, err := intercept.ParseArgvPattern(req.Pattern); err != nil {
tokens, err := intercept.ParseArgvPattern(req.Pattern)
if err != nil {
return fmt.Errorf("invalid argv pattern: %w", err)
}
if req.Action == "allow" {
if err := intercept.CheckArgvAllow(tokens); err != nil {
return err
}
}
default:
return fmt.Errorf("match_type must be string, regex or argv")
}
@@ -708,7 +776,18 @@ func (s *Server) interceptDetail(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not found")
return
}
writeJSON(w, 200, detail)
// The "always allow" button label: the prefix the endpoint would allow,
// derived here so the UI never parses the command.
out := struct {
*db.InterceptDetail
AlwaysAllowPrefix string `json:"always_allow_prefix,omitempty"`
}{InterceptDetail: detail}
if detail.Status == "pending" {
if prefix, err := intercept.AlwaysAllowPrefix(detail.ToolName, detail.ToolInput); err == nil {
out.AlwaysAllowPrefix = strings.Join(prefix, " ")
}
}
writeJSON(w, 200, out)
}
// The navigation endpoint returns only the original call and its paired result.
+147
View File
@@ -0,0 +1,147 @@
package server
import (
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
)
// PG test: needs a PostgreSQL database (ARTEX_PG_DSN); skipped without one.
// POST /api/intercept/pending/{id}/always-allow derives the rule from the
// stored row, never from the body, and leaves the request pending.
func TestInterceptAlwaysAllowPG(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("always-allow-test-key")}
h := s.Handler()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
const scope = "always-allow-http"
t.Cleanup(func() {
_, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, scope)
_, _ = d.Exec(`DELETE FROM intercept_rules WHERE name LIKE 'always-allow: %'`)
})
pending := func(tool, cmd string) int64 {
t.Helper()
input, _ := json.Marshal(map[string]string{"command": cmd})
id, err := d.CreateInterceptPending(0, 0, scope, "test", tool, input, "ask")
if err != nil {
t.Fatal(err)
}
return id
}
post := func(id int64, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest("POST", fmt.Sprintf("/api/intercept/pending/%d/always-allow", id), strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
ruleCount := func() int {
t.Helper()
rules, err := d.ListInterceptRules()
if err != nil {
t.Fatal(err)
}
return len(rules)
}
type result struct {
Rule db.InterceptRule `json:"rule"`
Created bool `json:"created"`
}
id := pending("Bash", "gh pr list --json title")
before := ruleCount()
// The body carries dummy tokens and another command: all of it is ignored.
dummy := `{"pending_id":1,"command":"rm -rf /","pattern":"[\"rm\"]","token":"dummy-token","access_token":"dummy"}`
rec := post(id, dummy)
if rec.Code != 200 {
t.Fatalf("always-allow: %d %s", rec.Code, rec.Body)
}
var got result
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
r := got.Rule
if !got.Created || r.Pattern != `["gh","pr","list"]` || r.MatchType != "argv" || r.MatchTarget != "tool_input" ||
r.Action != "allow" || !r.Enabled || r.Priority != -1000 || r.Name != "always-allow: gh pr list" ||
!r.TimeoutEnabled || r.TimeoutSeconds != 60 || r.TimeoutAction != "deny" {
t.Fatalf("rule %+v created=%v", r, got.Created)
}
if n := ruleCount(); n != before+1 {
t.Fatalf("rule count %d, want %d", n, before+1)
}
if p, err := d.GetInterceptPending(id); err != nil || p == nil || p.Status != "pending" {
t.Fatalf("pending row after always-allow: %+v %v", p, err)
}
// The cache was invalidated: the new rule matches at once.
if dec, ok := m.interceptor.Match("Bash", []byte(`{"command":"gh pr list --limit 5"}`)); !ok || dec.RuleID != r.ID {
t.Fatalf("match after creation: %+v %v", dec, ok)
}
// An identical rule is reused, not duplicated.
rec = post(pending("Bash", "gh pr list"), "")
var again result
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &again) != nil || again.Created || again.Rule.ID != r.ID {
t.Fatalf("second always-allow: %d %s", rec.Code, rec.Body)
}
if n := ruleCount(); n != before+1 {
t.Fatalf("rule count after reuse %d, want %d", n, before+1)
}
// Refusals: 400 and no rule.
for name, c := range map[string][2]string{
"not Bash": {"shell_open", "gh pr list"},
"compound": {"Bash", "gh pr list; id"},
"denylisted": {"Bash", "python3.11 -c 'import os'"},
"path to env": {"Bash", "/usr/bin/env gh pr list"},
} {
rid := pending(c[0], c[1])
if rec := post(rid, dummy); rec.Code != 400 {
t.Errorf("%s: %d %s, want 400", name, rec.Code, rec.Body)
}
if p, _ := d.GetInterceptPending(rid); p == nil || p.Status != "pending" {
t.Errorf("%s: pending row changed: %+v", name, p)
}
}
if n := ruleCount(); n != before+1 {
t.Fatalf("rule count after refusals %d, want %d", n, before+1)
}
// A decided request is refused.
decided := pending("Bash", "gh pr view 1")
if err := d.DecideInterceptPending(decided, "denied"); err != nil {
t.Fatal(err)
}
if rec := post(decided, ""); rec.Code != 409 {
t.Fatalf("decided request: %d %s, want 409", rec.Code, rec.Body)
}
// The detail carries the prefix for the button label.
req := httptest.NewRequest("GET", fmt.Sprintf("/api/intercept/history/%d", id), nil)
req.Header.Set("Authorization", "Bearer "+token)
drec := httptest.NewRecorder()
h.ServeHTTP(drec, req)
var detail struct {
Status string `json:"status"`
AlwaysAllowPrefix string `json:"always_allow_prefix"`
}
if drec.Code != 200 || json.Unmarshal(drec.Body.Bytes(), &detail) != nil || detail.AlwaysAllowPrefix != "gh pr list" || detail.Status != "pending" {
t.Fatalf("detail: %d %s", drec.Code, drec.Body)
}
}
+30 -1
View File
@@ -1,6 +1,10 @@
package server
import "testing"
import (
"testing"
"github.com/Autumn-27/artex/intercept"
)
func TestValidateArgvRuleReq(t *testing.T) {
base := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: `["git","push"]`, Action: "allow"}
@@ -23,3 +27,28 @@ func TestValidateArgvRuleReq(t *testing.T) {
}
}
}
// The manual rule form refuses an argv allow rule for a denylisted program,
// with the message of the "always allow" endpoint; deny and ask stay creatable.
func TestValidateArgvAllowDenylist(t *testing.T) {
for _, pattern := range []string{`["python"]`, `["python3.11"]`, `["/usr/bin/env"]`, `["find"]`, `["docker","run"]`} {
req := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: "allow"}
tokens, _ := intercept.ParseArgvPattern(pattern)
want := intercept.CheckArgvAllow(tokens)
if err := validateInterceptRuleReq(req); err == nil || want == nil || err.Error() != want.Error() {
t.Errorf("allow %s: %v, want %v", pattern, err, want)
}
for _, action := range []string{"deny", "ask"} {
req.Action = action
if err := validateInterceptRuleReq(req); err != nil {
t.Errorf("%s %s refused: %v", action, pattern, err)
}
}
}
for _, pattern := range []string{`["nmap"]`, `["git","push"]`} {
req := interceptRuleReq{Name: "r", MatchTarget: "tool_input", MatchType: "argv", Pattern: pattern, Action: "allow"}
if err := validateInterceptRuleReq(req); err != nil {
t.Errorf("allow %s refused: %v", pattern, err)
}
}
}
+1
View File
@@ -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/{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)
mux.HandleFunc("GET /api/intercept/history/{id}/execution", s.interceptExecution)
+35
View File
@@ -297,6 +297,38 @@ function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; bus
);
}
// AlwaysAllowAction creates an argv allow rule from a pending Bash request.
// The prefix comes from the server; the request itself stays pending.
function AlwaysAllowAction({ id, prefix }: { id: number; prefix?: string }) {
const [busy, setBusy] = React.useState(false);
async function create() {
setBusy(true);
try {
const res = await api.interceptAlwaysAllow(id);
toast.success(
res.created
? "Rule created, the request is still pending approval"
: "Rule already exists, the request is still pending approval",
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-1">
<Button size="sm" variant="outline" className="self-start" disabled={busy} onClick={() => void create()}>
{prefix ? `Toujours autoriser "${prefix} *"` : "Toujours autoriser"}
</Button>
<p className="text-muted-foreground text-xs">
The prefix does not constrain flags, and it does not cover what the program does next (git hooks, npm
scripts, docker images).
</p>
</div>
);
}
export function ApprovalDetail({
row,
busy,
@@ -418,6 +450,9 @@ export function ApprovalDetail({
) : null}
</dl>
{!readOnly ? <DecisionActions row={current} busy={busy} decide={decide} /> : null}
{!readOnly && current.status === "pending" && row.tool_name === "Bash" ? (
<AlwaysAllowAction id={row.id} prefix={detail?.always_allow_prefix} />
) : null}
</div>
</div>
{error ? (
+3
View File
@@ -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 }),
// 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`),
interceptExecution: (id: number, conversationId?: number) =>
get<import("@/lib/types").InterceptExecution>(
`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`,
+1
View File
@@ -1560,6 +1560,7 @@ export interface InterceptAudit {
}
export interface InterceptDetail extends InterceptApprovalRow {
audit: InterceptAudit | null;
always_allow_prefix?: string; // pending Bash request: the argv prefix "always allow" would allow
}
export type TrafficEvidenceRole = "baseline" | "proof" | "verification" | "supporting";