mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edf3f0fa7 | ||
|
|
62e65f1235 | ||
|
|
e2fd4908f7 | ||
|
|
2457a1f103 | ||
|
|
c5116f27ad |
@@ -52,6 +52,14 @@ AI autonomous penetration testing system (Go backend + Next.js frontend)
|
||||
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:
|
||||
|
||||
## Argv prefix rules
|
||||
|
||||
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.
|
||||
|
||||
- **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.
|
||||
|
||||
|
||||
## Traffic capture browser extension
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// A database created before argv rules has the old CHECK constraint on
|
||||
// match_type. Applying the schema again replaces it, idempotently, so an argv
|
||||
// rule can be stored; an unknown match_type is still refused.
|
||||
func TestInterceptRulesArgvConstraintMigration(t *testing.T) {
|
||||
d := openTargetDB(t)
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM intercept_rules WHERE name LIKE 'argv migration test%'`)
|
||||
_, _ = d.Exec(schemaSQL) // leave the current constraint in place
|
||||
})
|
||||
insert := func(name, matchType string) error {
|
||||
_, err := d.CreateInterceptRule(name, "tool_input", matchType, `["git","push"]`, "allow", "", -1000, true, false, 60, "deny")
|
||||
return err
|
||||
}
|
||||
// The constraint of an existing database, before this change.
|
||||
if _, err := d.Exec(`ALTER TABLE intercept_rules DROP CONSTRAINT IF EXISTS intercept_rules_match_type_check;
|
||||
ALTER TABLE intercept_rules ADD CONSTRAINT intercept_rules_match_type_check CHECK (match_type IN ('string', 'regex'))`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := insert("argv migration test old", "argv"); err == nil {
|
||||
t.Fatal("the old constraint accepted an argv rule")
|
||||
}
|
||||
for range 2 { // idempotent
|
||||
if _, err := d.Exec(schemaSQL); err != nil {
|
||||
t.Fatalf("apply schema: %v", err)
|
||||
}
|
||||
}
|
||||
if err := insert("argv migration test new", "argv"); err != nil {
|
||||
t.Fatalf("argv rule refused after the migration: %v", err)
|
||||
}
|
||||
if err := insert("argv migration test bad", "glob"); err == nil {
|
||||
t.Fatal("an unknown match_type was accepted")
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -1076,7 +1076,7 @@ CREATE TABLE IF NOT EXISTS intercept_rules (
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
match_target TEXT NOT NULL CHECK (match_target IN ('tool_name', 'tool_input')),
|
||||
match_type TEXT NOT NULL CHECK (match_type IN ('string', 'regex')),
|
||||
match_type TEXT NOT NULL CHECK (match_type IN ('string', 'regex', 'argv')),
|
||||
pattern TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'ask')),
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
@@ -1086,6 +1086,10 @@ CREATE TABLE IF NOT EXISTS intercept_rules (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
-- Allow argv prefix rules on databases created before them (pattern = JSON array of tokens).
|
||||
ALTER TABLE intercept_rules DROP CONSTRAINT IF EXISTS intercept_rules_match_type_check;
|
||||
ALTER TABLE intercept_rules ADD CONSTRAINT intercept_rules_match_type_check
|
||||
CHECK (match_type IN ('string','regex','argv'));
|
||||
DROP TRIGGER IF EXISTS trg_intercept_rules_upd ON intercept_rules;
|
||||
CREATE TRIGGER trg_intercept_rules_upd BEFORE UPDATE ON intercept_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
@@ -15,6 +15,7 @@ require (
|
||||
golang.org/x/net v0.62.0
|
||||
golang.org/x/text v0.43.0
|
||||
modernc.org/sqlite v1.52.0
|
||||
mvdan.cc/sh/v3 v3.14.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -31,7 +32,6 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
|
||||
@@ -6,7 +6,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -16,12 +15,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gaissmai/bart v0.26.0 h1:xOZ57E9hJLBiQaSyeZa9wgWhGuzfGACgqp4BE77OkO0=
|
||||
github.com/gaissmai/bart v0.26.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -58,7 +59,6 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -76,9 +76,8 @@ github.com/projectdiscovery/utils v0.7.3 h1:kX+77AA58yK6EZgkTRJEnK9V/7AZYzlXdcu/
|
||||
github.com/projectdiscovery/utils v0.7.3/go.mod h1:uDdQ3/VWomai98l+a3Ye/srDXdJ4xUIar/mSXlQ9gBM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
@@ -157,3 +156,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
|
||||
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package guard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// An argv allow rule is an allow like any other: a higher-priority deny rule
|
||||
// still wins, and the target gate still runs after it.
|
||||
func TestArgvAllowKeepsDenyAndTargetGate(t *testing.T) {
|
||||
ic := intercept.New(nil)
|
||||
ic.SetRulesForTest([]db.InterceptRule{
|
||||
{ID: 1, Name: "no force push", Enabled: true, Priority: 50, MatchTarget: "tool_input", MatchType: "string",
|
||||
Pattern: "--force", Action: "deny", Message: "force push is forbidden"},
|
||||
{ID: 2, Name: "argv allow curl", Enabled: true, Priority: -1000, MatchTarget: "tool_input", MatchType: "argv",
|
||||
Pattern: `["curl"]`, Action: "allow"},
|
||||
{ID: 3, Name: "argv allow git push", Enabled: true, Priority: -1000, MatchTarget: "tool_input", MatchType: "argv",
|
||||
Pattern: `["git","push"]`, Action: "allow"},
|
||||
})
|
||||
g := NewWithInterceptor(ic)
|
||||
gate := &fakeGate{t: t, check: unpinnedOnce, request: func(int, []intercept.TargetRequest, *intercept.Decision) intercept.TargetResult {
|
||||
return intercept.TargetResult{Code: intercept.TargetDenied, Host: "app.example.com", Port: 443, Reason: "the request was denied"}
|
||||
}}
|
||||
g.gate = gate
|
||||
ctx := gateOn(context.Background(), "7", time.Minute)
|
||||
|
||||
if block, msg := pre(g, ctx, "Bash", `{"command":"git push --force origin"}`); !block || !strings.Contains(msg, "force push is forbidden") {
|
||||
t.Fatalf("deny over argv allow: block=%v msg=%q", block, msg)
|
||||
}
|
||||
if gate.checks != 0 {
|
||||
t.Fatalf("the gate was reached on a deny: %d checks", gate.checks)
|
||||
}
|
||||
block, msg := pre(g, ctx, "Bash", `{"command":"curl https://app.example.com/"}`)
|
||||
if !block || !strings.Contains(msg, "TARGET_DENIED") {
|
||||
t.Fatalf("argv allow bypassed the target gate: block=%v msg=%q", block, msg)
|
||||
}
|
||||
if gate.checks != 1 || len(gate.asked) != 1 || gate.asks[0] != nil {
|
||||
t.Fatalf("checks=%d requests=%d asks=%v, want one check and one request without an ask", gate.checks, len(gate.asked), gate.asks)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
// argvArity is the code-owned arity table (a port of cyberstrike's BashArity):
|
||||
// how many leading tokens of a command form its human prefix, the program plus
|
||||
// its subcommands. The longest entry that matches the start of the argv wins;
|
||||
// a program that is not listed takes 1. It derives a prefix and nothing else:
|
||||
// the security checks are in ParseSimpleCommand and ArgvPrefix.
|
||||
var argvArity = map[string]int{
|
||||
"cat": 1, "cd": 1, "ls": 1, "rm": 1, "grep": 1, "head": 1, "tail": 1,
|
||||
"echo": 1, "pwd": 1, "find": 1, "curl": 1, "wget": 1, "nmap": 1,
|
||||
"git": 2, "npm": 2, "docker": 2, "kubectl": 2, "terraform": 2,
|
||||
"npm run": 3, "docker compose": 3, "kubectl rollout": 3, "git config": 3,
|
||||
"gcloud": 3, "gh": 3, "aws": 3,
|
||||
}
|
||||
|
||||
// argvPrefixLen returns the number of leading tokens that form the prefix.
|
||||
func argvPrefixLen(tokens []string) int {
|
||||
for n := len(tokens); n > 0; n-- {
|
||||
if a, ok := argvArity[strings.Join(tokens[:n], " ")]; ok {
|
||||
return min(a, len(tokens))
|
||||
}
|
||||
}
|
||||
return min(1, len(tokens))
|
||||
}
|
||||
|
||||
// ArgvPrefixOf returns the prefix of an argv: the program and its subcommands.
|
||||
func ArgvPrefixOf(tokens []string) []string { return tokens[:argvPrefixLen(tokens)] }
|
||||
|
||||
// argvWord is one word of a parsed command. Literal reports that the word is
|
||||
// fixed text: no expansion of any kind (no glob, tilde, brace, escape or
|
||||
// parameter), so Value is exactly what the program receives.
|
||||
type argvWord struct {
|
||||
Value string
|
||||
Literal bool
|
||||
}
|
||||
|
||||
var (
|
||||
errNotSimple = errors.New("not a single simple command")
|
||||
errBackground = errors.New("the command runs in the background")
|
||||
errRedirect = errors.New("the command has a redirection")
|
||||
errAssign = errors.New("the command has a prefix assignment")
|
||||
errWrapper = errors.New("the program is a wrapper that runs its arguments as a command")
|
||||
)
|
||||
|
||||
// argvWrappers are programs that run their arguments as another command: the
|
||||
// prefix of "command rm -rf /" would be ["command"], so no argv rule matches them.
|
||||
var argvWrappers = map[string]bool{"command": true, "builtin": true, "env": true, "exec": true, "eval": true}
|
||||
|
||||
// ParseSimpleCommand parses a shell command line and accepts exactly one
|
||||
// simple command: no assignment, redirection, pipeline, list (&&, ||, ;),
|
||||
// subshell, background job or compound command, no wrapper program (command,
|
||||
// builtin, env, exec, eval), and no command substitution, process
|
||||
// substitution, parameter or arithmetic expansion or extended glob anywhere in
|
||||
// the line. Arguments may hold plain globs and a tilde (they run nothing);
|
||||
// such a word is returned with Literal false. The grammar is Bash, the shell
|
||||
// the Bash tool runs the command with (norma tool/task.go shellCmd: bash -c).
|
||||
func ParseSimpleCommand(command string) ([]argvWord, error) {
|
||||
f, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(command), "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the command does not parse: %w", err)
|
||||
}
|
||||
if len(f.Stmts) != 1 {
|
||||
return nil, errNotSimple
|
||||
}
|
||||
st := f.Stmts[0]
|
||||
switch {
|
||||
case st.Background || st.Coprocess || st.Disown:
|
||||
return nil, errBackground
|
||||
case len(st.Redirs) > 0:
|
||||
return nil, errRedirect
|
||||
}
|
||||
call, ok := st.Cmd.(*syntax.CallExpr)
|
||||
if !ok || st.Negated {
|
||||
return nil, errNotSimple
|
||||
}
|
||||
if len(call.Assigns) > 0 {
|
||||
return nil, errAssign
|
||||
}
|
||||
if len(call.Args) == 0 {
|
||||
return nil, errNotSimple
|
||||
}
|
||||
var bad error
|
||||
syntax.Walk(f, func(n syntax.Node) bool {
|
||||
switch n.(type) {
|
||||
case *syntax.CmdSubst, *syntax.ProcSubst, *syntax.ParamExp, *syntax.ArithmExp, *syntax.ExtGlob, *syntax.BraceExp:
|
||||
bad = errors.New("the command contains an expansion or a substitution")
|
||||
}
|
||||
return bad == nil
|
||||
})
|
||||
if bad != nil {
|
||||
return nil, bad
|
||||
}
|
||||
words := make([]argvWord, 0, len(call.Args))
|
||||
for _, w := range call.Args {
|
||||
word, ok := argvWordOf(w)
|
||||
if !ok {
|
||||
return nil, errors.New("the command contains an expansion or a substitution")
|
||||
}
|
||||
words = append(words, word)
|
||||
}
|
||||
if argvWrappers[path.Base(words[0].Value)] {
|
||||
return nil, errWrapper
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
|
||||
// argvWordOf reads one word. ok is false for a part that is not text.
|
||||
func argvWordOf(w *syntax.Word) (argvWord, bool) {
|
||||
var sb strings.Builder
|
||||
literal := true
|
||||
for _, part := range w.Parts {
|
||||
switch p := part.(type) {
|
||||
case *syntax.Lit:
|
||||
// Unquoted text: a glob, a tilde, a brace or an escape makes the word
|
||||
// differ from its text, so it is not literal.
|
||||
if strings.ContainsAny(p.Value, "*?[]{}~\\") {
|
||||
literal = false
|
||||
}
|
||||
sb.WriteString(p.Value)
|
||||
case *syntax.SglQuoted:
|
||||
if p.Dollar {
|
||||
literal = false
|
||||
}
|
||||
sb.WriteString(p.Value)
|
||||
case *syntax.DblQuoted:
|
||||
if p.Dollar {
|
||||
literal = false
|
||||
}
|
||||
for _, q := range p.Parts {
|
||||
lit, ok := q.(*syntax.Lit)
|
||||
if !ok {
|
||||
return argvWord{}, false
|
||||
}
|
||||
if strings.Contains(lit.Value, "\\") {
|
||||
literal = false
|
||||
}
|
||||
sb.WriteString(lit.Value)
|
||||
}
|
||||
default:
|
||||
return argvWord{}, false
|
||||
}
|
||||
}
|
||||
return argvWord{Value: sb.String(), Literal: literal}, true
|
||||
}
|
||||
|
||||
// ArgvPrefix parses a command and derives its prefix with the arity table. It
|
||||
// refuses (returns an error) when the command is not one simple command, when
|
||||
// a prefix token is not literal, and when a prefix token is a flag: a flag
|
||||
// before a subcommand ("git -C /repo push") yields no prefix at all, rather
|
||||
// than an invented one.
|
||||
func ArgvPrefix(command string) ([]string, error) {
|
||||
words, err := ParseSimpleCommand(command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens := make([]string, len(words))
|
||||
for i, w := range words {
|
||||
tokens[i] = w.Value
|
||||
if !w.Literal {
|
||||
tokens[i] = "\x00" // never a key of the arity table
|
||||
}
|
||||
}
|
||||
n := argvPrefixLen(tokens)
|
||||
for _, w := range words[:n] {
|
||||
if !w.Literal {
|
||||
return nil, errors.New("a token of the prefix is not literal text")
|
||||
}
|
||||
if err := checkArgvToken(w.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return tokens[:n], nil
|
||||
}
|
||||
|
||||
// argvMaxTokens bounds the length of an argv pattern.
|
||||
const argvMaxTokens = 8
|
||||
|
||||
// checkArgvToken applies the per-token pattern rules.
|
||||
func checkArgvToken(tok string) error {
|
||||
if tok == "" {
|
||||
return errors.New("a token is empty")
|
||||
}
|
||||
if strings.HasPrefix(tok, "-") {
|
||||
return fmt.Errorf("token %q is a flag; a prefix holds the program and its subcommands only", tok)
|
||||
}
|
||||
if strings.ContainsAny(tok, ";&|><`$()") || strings.IndexFunc(tok, unicode.IsSpace) >= 0 {
|
||||
return fmt.Errorf("token %q contains a shell metacharacter or a space", tok)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseArgvPattern validates the pattern of an argv rule: a JSON array of 1 to
|
||||
// 8 tokens, none empty, none starting with '-', none holding a shell
|
||||
// metacharacter (; & | > < ` $ ( )) or whitespace.
|
||||
func ParseArgvPattern(pattern string) ([]string, error) {
|
||||
var tokens []string
|
||||
if err := json.Unmarshal([]byte(pattern), &tokens); err != nil {
|
||||
return nil, fmt.Errorf(`an argv pattern is a JSON array of strings, for example ["git","push"]: %w`, err)
|
||||
}
|
||||
if len(tokens) < 1 || len(tokens) > argvMaxTokens {
|
||||
return nil, fmt.Errorf("an argv pattern has 1 to %d tokens, got %d", argvMaxTokens, len(tokens))
|
||||
}
|
||||
for _, tok := range tokens {
|
||||
if err := checkArgvToken(tok); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// argvRuleMatches reports whether a Bash call's command derives exactly the
|
||||
// prefix tokens. Flags after the prefix are not constrained.
|
||||
func argvRuleMatches(tokens []string, toolName string, input []byte) bool {
|
||||
if toolName != "Bash" || len(tokens) == 0 {
|
||||
return false
|
||||
}
|
||||
var in struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if json.Unmarshal(input, &in) != nil {
|
||||
return false
|
||||
}
|
||||
prefix, err := ArgvPrefix(in.Command)
|
||||
if err != nil || len(prefix) != len(tokens) {
|
||||
return false
|
||||
}
|
||||
for i := range prefix {
|
||||
if prefix[i] != tokens[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestArgvArityTable(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
argv, want []string
|
||||
}{
|
||||
{[]string{"git", "push", "origin", "main"}, []string{"git", "push"}},
|
||||
{[]string{"git", "config", "user.name", "x"}, []string{"git", "config", "user.name"}},
|
||||
{[]string{"npm", "install", "left-pad"}, []string{"npm", "install"}},
|
||||
{[]string{"npm", "run", "build", "--watch"}, []string{"npm", "run", "build"}},
|
||||
{[]string{"docker", "compose", "up", "-d"}, []string{"docker", "compose", "up"}},
|
||||
{[]string{"docker", "ps"}, []string{"docker", "ps"}},
|
||||
{[]string{"kubectl", "rollout", "status", "x"}, []string{"kubectl", "rollout", "status"}},
|
||||
{[]string{"gh", "pr", "list", "--json"}, []string{"gh", "pr", "list"}},
|
||||
{[]string{"nmap", "-p", "80", "x"}, []string{"nmap"}}, // fallback to tokens[0]
|
||||
{[]string{"unknowntool", "a", "b"}, []string{"unknowntool"}}, // fallback to tokens[0]
|
||||
{[]string{"cat", "/etc/hosts"}, []string{"cat"}},
|
||||
{[]string{"git"}, []string{"git"}}, // arity longer than the argv
|
||||
{nil, []string{}},
|
||||
} {
|
||||
if got := ArgvPrefixOf(c.argv); !slices.Equal(got, c.want) {
|
||||
t.Errorf("prefix(%q) = %q, want %q", c.argv, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgvPrefixParsing(t *testing.T) {
|
||||
ok := []struct {
|
||||
cmd string
|
||||
want []string
|
||||
}{
|
||||
{"nmap -p 80 10.0.0.1", []string{"nmap"}},
|
||||
{"git push --force origin main", []string{"git", "push"}},
|
||||
{" git push ", []string{"git", "push"}},
|
||||
{`'git' "push" origin`, []string{"git", "push"}}, // quoting is removed
|
||||
{`git 'pu'"sh"`, []string{"git", "push"}},
|
||||
{`nmap "a b" 'c;d'`, []string{"nmap"}}, // metacharacters quoted in arguments
|
||||
{`nmap *.txt ~/scan`, []string{"nmap"}}, // glob and tilde after the prefix
|
||||
{`ls -la # $(id) in a comment`, []string{"ls"}}, // a comment runs nothing
|
||||
{"npm run build", []string{"npm", "run", "build"}},
|
||||
}
|
||||
for _, c := range ok {
|
||||
got, err := ArgvPrefix(c.cmd)
|
||||
if err != nil || !slices.Equal(got, c.want) {
|
||||
t.Errorf("ArgvPrefix(%q) = %q, %v; want %q", c.cmd, got, err, c.want)
|
||||
}
|
||||
}
|
||||
refused := []string{
|
||||
"",
|
||||
"nmap x; rm -rf /", // list
|
||||
"nmap x && id", // and
|
||||
"nmap x || id", // or
|
||||
"nmap x | tee out", // pipeline
|
||||
"(nmap x)", // subshell
|
||||
"{ nmap x; }", // block
|
||||
"! nmap x", // negation
|
||||
"nmap $(id)", // command substitution in an argument
|
||||
"nmap `id`", // backquote substitution
|
||||
`nmap "$(id)"`, // substitution inside double quotes
|
||||
"nmap x$(id)y", // substitution inside a word
|
||||
"nmap <(id)", // process substitution
|
||||
"nmap $HOME", // parameter expansion
|
||||
`nmap "${x}"`, // parameter expansion in quotes
|
||||
"nmap $((1+2))", // arithmetic expansion
|
||||
"nmap @(a|b)", // extended glob
|
||||
"$X push", // expanded program
|
||||
"git $SUB", // expanded subcommand
|
||||
"git *", // glob in the prefix
|
||||
"git ~", // tilde in the prefix
|
||||
`g\it push`, // escape in the prefix
|
||||
"git -C /repo push", // flag before the subcommand
|
||||
"git --no-pager log", // flag before the subcommand
|
||||
"if true; then nmap x; fi", // compound
|
||||
"for i in 1; do nmap; done", // loop
|
||||
"nmap x\nid", // two statements
|
||||
"time nmap x", // time clause
|
||||
"export X=1", // declaration
|
||||
"'' push", // empty program
|
||||
"nmap 'unterminated", // parse error
|
||||
}
|
||||
for _, cmd := range refused {
|
||||
if got, err := ArgvPrefix(cmd); err == nil {
|
||||
t.Errorf("ArgvPrefix(%q) = %q, want a refusal", cmd, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refusedWith checks that each command is refused by the parser with want, and
|
||||
// that an argv rule for its program matches nothing.
|
||||
func refusedWith(t *testing.T, want error, cmds ...string) {
|
||||
t.Helper()
|
||||
for _, cmd := range cmds {
|
||||
if _, err := ParseSimpleCommand(cmd); !errors.Is(err, want) {
|
||||
t.Errorf("ParseSimpleCommand(%q) error = %v, want %v", cmd, err, want)
|
||||
}
|
||||
input, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
for _, p := range [][]string{{"nmap"}, {"command"}, {"builtin"}, {"env"}} {
|
||||
if argvRuleMatches(p, "Bash", input) {
|
||||
t.Errorf("%q matched the argv rule %q", cmd, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgvRejectsBackground(t *testing.T) {
|
||||
refusedWith(t, errBackground, "nmap x &", "nmap -p 80 x&")
|
||||
}
|
||||
|
||||
func TestArgvRejectsRedirections(t *testing.T) {
|
||||
refusedWith(t, errRedirect, "nmap x > out", "nmap x >> out", "nmap x 2>&1", "nmap x < in",
|
||||
"nmap x <<EOF\nhi\nEOF", "nmap x <<< hi", "nmap x &> out", "> out nmap x")
|
||||
}
|
||||
|
||||
func TestArgvRejectsPrefixAssignments(t *testing.T) {
|
||||
refusedWith(t, errAssign, "FOO=bar nmap x", "LD_PRELOAD=/tmp/x.so nmap", "A=1 B=2 nmap x", "PATH=. nmap")
|
||||
}
|
||||
|
||||
func TestArgvRejectsWrappers(t *testing.T) {
|
||||
refusedWith(t, errWrapper, "command nmap x", "command -p rm -rf /", "builtin echo x", "env nmap x",
|
||||
"env FOO=1 nmap", "/usr/bin/env nmap", "exec nmap x", "eval nmap x", `"command" rm x`)
|
||||
}
|
||||
|
||||
// A command that does not parse is no match, never an error: Match reports
|
||||
// no rule and the normal flow (judge, approval) continues.
|
||||
func TestArgvParseFailureIsNoMatch(t *testing.T) {
|
||||
ic := New(nil)
|
||||
ic.SetRulesForTest([]db.InterceptRule{{ID: 1, Name: "argv allow nmap", Enabled: true, MatchTarget: "tool_input",
|
||||
MatchType: "argv", Pattern: `["nmap"]`, Action: "allow"}})
|
||||
for _, input := range []string{`{"command":"nmap 'unterminated"}`, `{"command":"nmap $("}`, `{"command":""}`, `{}`, `not json`} {
|
||||
if dec, ok := ic.Match("Bash", []byte(input)); ok {
|
||||
t.Errorf("%s matched: %+v", input, dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgvRuleMatching(t *testing.T) {
|
||||
bash := func(cmd string) []byte {
|
||||
b, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
return b
|
||||
}
|
||||
for _, c := range []struct {
|
||||
pattern []string
|
||||
tool string
|
||||
cmd string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"nmap"}, "Bash", "nmap -p 80 x", true},
|
||||
{[]string{"nmap"}, "Bash", "nmapx -p 80 x", false},
|
||||
{[]string{"nmap"}, "Bash", "nmap x; rm -rf /", false},
|
||||
{[]string{"nmap"}, "Bash", "nmap $(rm -rf /)", false},
|
||||
{[]string{"nmap"}, "Bash", "nmap x`id`", false},
|
||||
{[]string{"nmap"}, "Bash", "/usr/bin/nmap x", false},
|
||||
{[]string{"nmap"}, "shell_open", "nmap x", false}, // Bash only
|
||||
{[]string{"git", "push"}, "Bash", "git push --force", true},
|
||||
{[]string{"git", "push"}, "Bash", "git -C /repo push", false},
|
||||
{[]string{"git", "push"}, "Bash", "git pushx", false},
|
||||
{[]string{"git"}, "Bash", "git push", false}, // strict equality with the derived prefix
|
||||
{[]string{"git", "push", "origin"}, "Bash", "git push origin", false},
|
||||
} {
|
||||
if got := argvRuleMatches(c.pattern, c.tool, bash(c.cmd)); got != c.want {
|
||||
t.Errorf("%q on %s %q = %v, want %v", c.pattern, c.tool, c.cmd, got, c.want)
|
||||
}
|
||||
}
|
||||
if argvRuleMatches([]string{"nmap"}, "Bash", []byte(`not json`)) {
|
||||
t.Error("unreadable input matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgvPattern(t *testing.T) {
|
||||
if got, err := ParseArgvPattern(`["git","push"]`); err != nil || !slices.Equal(got, []string{"git", "push"}) {
|
||||
t.Fatalf("valid pattern: %q %v", got, err)
|
||||
}
|
||||
for name, p := range map[string]string{
|
||||
"not json": "git push",
|
||||
"not strings": `[1,2]`,
|
||||
"empty array": `[]`,
|
||||
"empty token": `["git",""]`,
|
||||
"flag token": `["git","--force"]`,
|
||||
"dash token": `["-rf"]`,
|
||||
"semicolon": `["nmap;id"]`,
|
||||
"ampersand": `["a&b"]`,
|
||||
"pipe": `["a|b"]`,
|
||||
"redirect": `["a>b"]`,
|
||||
"input": `["a<b"]`,
|
||||
"backquote": "[\"a`b\"]",
|
||||
"dollar": `["$HOME"]`,
|
||||
"parenthesis": `["a(b)"]`,
|
||||
"space": `["git push"]`,
|
||||
"newline": `["a\nb"]`,
|
||||
"tab": `["a\tb"]`,
|
||||
"nine tokens": `["a","b","c","d","e","f","g","h","i"]`,
|
||||
"empty string": ``,
|
||||
} {
|
||||
if _, err := ParseArgvPattern(p); err == nil {
|
||||
t.Errorf("%s: %q accepted", name, p)
|
||||
}
|
||||
}
|
||||
if _, err := ParseArgvPattern(`["a","b","c","d","e","f","g","h"]`); err != nil {
|
||||
t.Errorf("eight tokens refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An argv allow rule takes its place in the priority order: a deny rule with a
|
||||
// higher priority still wins, and an argv rule with a bad pattern is not loaded.
|
||||
func TestArgvAllowBelowDeny(t *testing.T) {
|
||||
ic := New(nil)
|
||||
ic.SetRulesForTest([]db.InterceptRule{
|
||||
{ID: 1, Name: "no force push", Enabled: true, Priority: 50, MatchTarget: "tool_input", MatchType: "regex",
|
||||
Pattern: `--force`, Action: "deny"},
|
||||
{ID: 2, Name: "argv allow git push", Enabled: true, Priority: -1000, MatchTarget: "tool_input", MatchType: "argv",
|
||||
Pattern: `["git","push"]`, Action: "allow"},
|
||||
})
|
||||
dec, ok := ic.Match("Bash", []byte(`{"command":"git push --force"}`))
|
||||
if !ok || dec.Action != "deny" || dec.RuleID != 1 {
|
||||
t.Fatalf("force push: %+v %v, want the deny rule", dec, ok)
|
||||
}
|
||||
dec, ok = ic.Match("Bash", []byte(`{"command":"git push origin"}`))
|
||||
if !ok || dec.Action != "allow" || dec.RuleID != 2 {
|
||||
t.Fatalf("push: %+v %v, want the argv allow", dec, ok)
|
||||
}
|
||||
if _, ok := ic.Match("Bash", []byte(`{"command":"git push; id"}`)); ok {
|
||||
t.Fatal("a compound command matched the argv rule")
|
||||
}
|
||||
if _, ok := compileRule(db.InterceptRule{MatchType: "argv", Pattern: `["-x"]`}); ok {
|
||||
t.Fatal("an argv rule with a flag token compiled")
|
||||
}
|
||||
}
|
||||
+40
-12
@@ -110,10 +110,33 @@ func TargetScopeFromContext(ctx context.Context) (s TargetScope, ok bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// compiledRule is an InterceptRule with the regex pre-compiled (nil for string rules).
|
||||
// compiledRule is an InterceptRule with the regex pre-compiled (nil for string rules)
|
||||
// and the argv tokens pre-parsed (nil for non-argv rules).
|
||||
type compiledRule struct {
|
||||
db.InterceptRule
|
||||
re *regexp.Regexp
|
||||
re *regexp.Regexp
|
||||
argv []string
|
||||
}
|
||||
|
||||
// compileRule prepares a rule for matching. ok is false for a rule whose
|
||||
// pattern does not compile; such a rule is skipped rather than crashing.
|
||||
func compileRule(r db.InterceptRule) (compiledRule, bool) {
|
||||
cr := compiledRule{InterceptRule: r}
|
||||
switch r.MatchType {
|
||||
case "regex":
|
||||
re, err := regexp.Compile(r.Pattern)
|
||||
if err != nil {
|
||||
return cr, false
|
||||
}
|
||||
cr.re = re
|
||||
case "argv":
|
||||
tokens, err := ParseArgvPattern(r.Pattern)
|
||||
if err != nil {
|
||||
return cr, false
|
||||
}
|
||||
cr.argv = tokens
|
||||
}
|
||||
return cr, true
|
||||
}
|
||||
|
||||
// pendingManager tracks in-flight approval requests via per-waiter channels. A
|
||||
@@ -260,13 +283,9 @@ func (i *Interceptor) loadLocked() error {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
cr := compiledRule{InterceptRule: r}
|
||||
if r.MatchType == "regex" {
|
||||
re, err := regexp.Compile(r.Pattern)
|
||||
if err != nil {
|
||||
continue // skip rules with bad regex rather than crashing
|
||||
}
|
||||
cr.re = re
|
||||
cr, ok := compileRule(r)
|
||||
if !ok {
|
||||
continue // skip rules with a bad pattern rather than crashing
|
||||
}
|
||||
out = append(out, cr)
|
||||
}
|
||||
@@ -716,9 +735,9 @@ func (i *Interceptor) SetRulesForTest(rules []db.InterceptRule) {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
cr := compiledRule{InterceptRule: r}
|
||||
if r.MatchType == "regex" {
|
||||
cr.re = regexp.MustCompile(r.Pattern)
|
||||
cr, ok := compileRule(r)
|
||||
if !ok {
|
||||
panic("SetRulesForTest: bad pattern " + r.Pattern)
|
||||
}
|
||||
out = append(out, cr)
|
||||
}
|
||||
@@ -732,6 +751,15 @@ 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
|
||||
switch r.MatchTarget {
|
||||
case "tool_name":
|
||||
|
||||
+88
-2
@@ -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.
|
||||
@@ -663,8 +725,21 @@ func validateInterceptRuleReq(req interceptRuleReq) error {
|
||||
}
|
||||
switch req.MatchType {
|
||||
case "string", "regex":
|
||||
case "argv":
|
||||
if req.MatchTarget != "tool_input" {
|
||||
return fmt.Errorf("an argv rule matches tool_input (the Bash command)")
|
||||
}
|
||||
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 or regex")
|
||||
return fmt.Errorf("match_type must be string, regex or argv")
|
||||
}
|
||||
if req.Pattern == "" {
|
||||
return fmt.Errorf("pattern cannot be empty")
|
||||
@@ -701,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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package server
|
||||
|
||||
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"}
|
||||
if err := validateInterceptRuleReq(base); err != nil {
|
||||
t.Fatalf("valid argv rule refused: %v", err)
|
||||
}
|
||||
for name, mut := range map[string]func(*interceptRuleReq){
|
||||
"tool_name target": func(r *interceptRuleReq) { r.MatchTarget = "tool_name" },
|
||||
"empty pattern": func(r *interceptRuleReq) { r.Pattern = "" },
|
||||
"empty token": func(r *interceptRuleReq) { r.Pattern = `["git",""]` },
|
||||
"flag token": func(r *interceptRuleReq) { r.Pattern = `["git","-C"]` },
|
||||
"metacharacter": func(r *interceptRuleReq) { r.Pattern = `["nmap;id"]` },
|
||||
"nine tokens": func(r *interceptRuleReq) { r.Pattern = `["a","b","c","d","e","f","g","h","i"]` },
|
||||
"not an array": func(r *interceptRuleReq) { r.Pattern = `git push` },
|
||||
} {
|
||||
req := base
|
||||
mut(&req)
|
||||
if err := validateInterceptRuleReq(req); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -149,7 +149,7 @@ type RuleForm = {
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
match_target: "tool_name" | "tool_input";
|
||||
match_type: "string" | "regex";
|
||||
match_type: "string" | "regex" | "argv";
|
||||
pattern: string;
|
||||
action: InterceptAction;
|
||||
message: string;
|
||||
@@ -172,6 +172,17 @@ const defaultForm = (): RuleForm => ({
|
||||
timeout_action: "deny",
|
||||
});
|
||||
|
||||
// An argv pattern (JSON array of tokens) shown as a prefix: ["nmap"] -> "nmap *".
|
||||
function argvLabel(pattern: string): string {
|
||||
try {
|
||||
const tokens: unknown = JSON.parse(pattern);
|
||||
if (Array.isArray(tokens)) return `${tokens.join(" ")} *`;
|
||||
} catch {
|
||||
// not a JSON array: show the raw pattern
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
// ---- small components ----
|
||||
|
||||
function ActionBadge({ action }: { action: InterceptAction }) {
|
||||
@@ -720,12 +731,12 @@ export default function InterceptPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rule.match_type === "regex" ? "Regex" : "String"}
|
||||
{rule.match_type === "regex" ? "Regex" : rule.match_type === "argv" ? "Argv prefix" : "String"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px]">
|
||||
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
|
||||
{rule.pattern}
|
||||
{rule.match_type === "argv" ? argvLabel(rule.pattern) : rule.pattern}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -814,19 +825,24 @@ export default function InterceptPage() {
|
||||
<Field label="Match type">
|
||||
<Select
|
||||
value={form.match_type}
|
||||
onValueChange={(v) => set({ match_type: v as RuleForm["match_type"] })}
|
||||
onValueChange={(v) => {
|
||||
const match_type = v as RuleForm["match_type"];
|
||||
// An argv rule always matches the Bash command in tool_input.
|
||||
set(match_type === "argv" ? { match_type, match_target: "tool_input" } : { match_type });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">String contains</SelectItem>
|
||||
<SelectItem value="regex">Regular expression</SelectItem>
|
||||
<SelectItem value="argv">Argv prefix (Bash only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Pattern">
|
||||
<Input
|
||||
placeholder={form.match_type === "regex" ? "^Bash$" : "rm -rf"}
|
||||
placeholder={form.match_type === "regex" ? "^Bash$" : form.match_type === "argv" ? '["git","push"]' : "rm -rf"}
|
||||
value={form.pattern}
|
||||
onChange={(e) => set({ pattern: e.target.value })}
|
||||
className={regexErr ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||
@@ -837,6 +853,13 @@ export default function InterceptPage() {
|
||||
{regexWarn && (
|
||||
<p className="text-xs text-amber-600 mt-1">Contains Go RE2 extended syntax (e.g. <code className="font-mono">(?i)</code>); the browser cannot preview it, and the server validates on submission</p>
|
||||
)}
|
||||
{form.match_type === "argv" && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
JSON array of 1 to 8 tokens: the program and its subcommands, no flags. Matches a single simple Bash command
|
||||
whose prefix is exactly <code className="font-mono">{argvLabel(form.pattern)}</code>. Flags are not constrained:
|
||||
<code className="font-mono"> ["git","push"]</code> also covers <code className="font-mono">git push --force</code>.
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}` : ""}`,
|
||||
|
||||
@@ -1278,7 +1278,8 @@ export interface Stats {
|
||||
// ---- Intercept Rules ----
|
||||
export type InterceptAction = "allow" | "deny" | "ask";
|
||||
export type InterceptMatchTarget = "tool_name" | "tool_input";
|
||||
export type InterceptMatchType = "string" | "regex";
|
||||
// argv: pattern is a JSON array of tokens, the prefix of a Bash command (program and subcommands; flags are not constrained).
|
||||
export type InterceptMatchType = "string" | "regex" | "argv";
|
||||
|
||||
export interface InterceptRule {
|
||||
id: number;
|
||||
@@ -1559,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";
|
||||
|
||||
Reference in New Issue
Block a user