diff --git a/docs/aw-security.md b/docs/aw-security.md new file mode 100644 index 00000000..c4cb7f8f --- /dev/null +++ b/docs/aw-security.md @@ -0,0 +1,39 @@ +## Security Model + +Agentic Workflows (AW) adopts a layered approach that combines substrate-enforced isolation, declarative specification, and staged execution. Each layer enforces distinct security properties under different assumptions and constrains the impact of failures above it. + +### Threat Model + +We consider an adversary that may compromise untrusted user-level components, e.g., containers, and causes them to behave arbitrarily within the privileges granted to them. The adversary may attempt to: + +- Access or corrupt the memory or state of other components +- Communicate over unintended channels +- Abuse legitimate channels to perform unintended actions +- Confuse higher-level control logic by deviating from expected workflows + +We assume the adversary does not compromise the underlying hardware or cryptographic primitives. Attacks exploiting side channels and covert channels are also out of scope. + +--- + +### Layer 1: Substrate-Level Trust + +AWs run on a GitHub Actions runner virtual machine (VM) and trust Actions' hardware and kernel-level enforcement mechanisms, including the CPU, MMU, kernel, and container runtime. AW also relies on two privileged containers: (1) a network firewall that is trusted to configure connectivity for other components via `iptables`, and (2) an MCP Gateway that is trusted to configure and spawn isolated containers, e.g., local MCP servers. Collectively, the substrate level ensures memory isolation between components, CPU and resource isolation, mediation of privileged operations and system calls, and explicit, kernel-enforced communication boundaries. These guarantees hold even if an untrusted user-level component is fully compromised and executes arbitrary code. Trust violations at the substrate level require vulnerabilities in the firewall, MCP Gateway, container runtime, kernel, hypervisor, or hardware. If this layer fails, higher-level security guarantees may not hold. + +--- + +### Layer 2: Configuration-Level Trust + +AW trusts declarative configuration artifacts, e.g., Action steps, network-firewall policies, MCP server configurations, and the toolchains that interpret them to correctly instantiate system structure and connectivity. The configuration level constrains which components are loaded, how components are connected, which communication channels are permitted, and what component privileges are assigned. Externally minted authentication tokens, e.g., agent API keys and GitHub access tokens, are a critical configuration input and are treated as imported capabilities that bound components' external effects; declarative configuration controls their distribution, e.g., which tokens are loaded into which containers. Security violations arise due to misconfigurations, overly permissive specifications, and limitations of the declarative model. This layer defines what components exist and how they communicate, but it does not constrain how components use those channels over time. + +--- + +### Layer 3: Plan-Level Trust + +AW additionally relies on plan-level trust to constrain component behavior over time. At this layer, the trusted compiler decomposes a workflow into stages. For each stage, the plan specifies (1) which components are active and their permissions, (2) the data produced by the stage, and (3) how that data may be consumed by subsequent stages. In particular, plan-level trust ensures that important external side effects are explicit and undergo thorough vetting. + +A primary instantiation of plan-level trust is the **SafeOutputs** subsystem. SafeOutputs is a set of trusted components that operations on external state. An agent can interact with read-only MCP servers, e.g., the GitHub MCP server, but externalized writes, such as creating GitHub pull requests, are buffered as artifacts by SafeOutputs rather than applied immediately. When the agent finishes, SafeOutput's buffered artifacts can be processed by a deterministic sequence of filters and analyses defined by configuration. These checks can include structural constraints (e.g., limiting the number of pull requests), policy enforcement, and automated sanitization to ensure that sensitive information such as authentication tokens are not exported. These filtered and transformed artifacts are passed to a subsequent stage in which they are externalized. + +Violations at of the planning layer arise from incorrect plan construction, incomplete or overly permissive stage definitions, or errors in the enforcement of plan transitions. This layer does not protect against failures of substrate-level isolation or mis-allocation of permissions at credential-minting or configuration time. However, it limits the blast radius of a compromised component to the stage in which it is active and its influence the artifacts passed to the next stage. + +--- + diff --git a/go.mod b/go.mod index 8a8952bf..d1f49b13 100644 --- a/go.mod +++ b/go.mod @@ -14,16 +14,3 @@ require ( github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/stretchr/testify v1.11.1 ) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/jsonschema-go v0.3.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/itchyny/timefmt-go v0.1.7 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.39.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum index 76b6e1f9..d4512b7e 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,29 @@ +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/go-yaml v0.0.0-20251001235044-fca9a0999f15/go.mod h1:Tmbz8uw5I/I6NvVpEGuhzlElCGS5hPoXJkt7l+ul6LE= github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -24,20 +35,103 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/difc/DIFC_RULES.md b/internal/difc/DIFC_RULES.md new file mode 100644 index 00000000..5ec62a28 --- /dev/null +++ b/internal/difc/DIFC_RULES.md @@ -0,0 +1,153 @@ +# Decentralized Information Flow Control (DIFC) Rules + +This document explains the DIFC labeling system used in this package. All implementations and tests MUST follow these rules. + +## Overview + +DIFC uses two types of labels to control information flow: + +- **Secrecy Labels**: Control who can read confidential information +- **Integrity Labels**: Control who can modify trusted resources + +Both agents and resources have secrecy and integrity labels. Labels are sets of tags (strings). + +## Core Rules + +### Notation + +- `A.secrecy` = Agent's secrecy label (set of tags) +- `A.integrity` = Agent's integrity label (set of tags) +- `R.secrecy` = Resource's secrecy label (set of tags) +- `R.integrity` = Resource's integrity label (set of tags) +- `⊇` means "is a superset of" (contains all elements of) + +### Read Access Rules + +For an agent to **READ** a resource: + +1. **Secrecy Check**: `A.secrecy ⊇ R.secrecy` + - Agent must have clearance for all secrecy tags on the resource + - *Example*: To read a `{secret, confidential}` document, agent must have at least `{secret, confidential}` in its secrecy label + +2. **Integrity Check**: `R.integrity ⊇ A.integrity` + - Resource must be at least as trustworthy as the agent requires + - *Example*: If agent requires `{verified}` integrity, resource must have at least `{verified}` + +### Write Access Rules + +For an agent to **WRITE** to a resource: + +1. **Secrecy Check**: `R.secrecy ⊇ A.secrecy` + - Resource must accept all agent's secrecy tags (no information leak) + - *Example*: Agent with `{secret}` cannot write to a `{}` (public) resource + +2. **Integrity Check**: `A.integrity ⊇ R.integrity` + - Agent must be at least as trustworthy as resource requires + - *Example*: To write to a `{production}` resource, agent must have at least `{production}` integrity + +### Read-Write Access + +For read-write operations, BOTH read AND write rules must be satisfied. + +## Key Examples + +### Example 1: Secret Agent Cannot Write to Public Resource + +``` +Agent: secrecy={secret}, integrity={} +Resource: secrecy={}, integrity={} + +Write Check: + Secrecy: R.secrecy ⊇ A.secrecy → {} ⊇ {secret} → FALSE + Result: DENIED (would leak secret information to public) +``` + +### Example 2: High-Integrity Agent Cannot Read Low-Integrity Resource + +``` +Agent: secrecy={}, integrity={trusted, verified} +Resource: secrecy={}, integrity={} + +Read Check: + Integrity: R.integrity ⊇ A.integrity → {} ⊇ {trusted, verified} → FALSE + Result: DENIED (resource is not trustworthy enough for agent) +``` + +### Example 3: Successful Read of Secret Document + +``` +Agent: secrecy={secret, confidential}, integrity={} +Resource: secrecy={secret}, integrity={} + +Read Check: + Secrecy: A.secrecy ⊇ R.secrecy → {secret, confidential} ⊇ {secret} → TRUE + Integrity: R.integrity ⊇ A.integrity → {} ⊇ {} → TRUE + Result: ALLOWED +``` + +### Example 4: Successful Write to Production Database + +``` +Agent: secrecy={}, integrity={production, verified} +Resource: secrecy={}, integrity={production} + +Write Check: + Secrecy: R.secrecy ⊇ A.secrecy → {} ⊇ {} → TRUE + Integrity: A.integrity ⊇ R.integrity → {production, verified} ⊇ {production} → TRUE + Result: ALLOWED +``` + +## Public Internet Analogy + +The **public internet** has empty labels: `secrecy={}, integrity={}`. + +- An agent with `secrecy={secret}` **CANNOT write** to the public internet + - Because: `{} ⊇ {secret}` is FALSE (would leak secrets) + +- An agent with `integrity={trusted}` **CANNOT read** from the public internet + - Because: `{} ⊇ {trusted}` is FALSE (source not trusted enough) + +## Implementation Notes + +### CheckFlow Function + +The `CheckFlow(target)` method checks if `source ⊆ target` (source has no tags that target doesn't have): + +```go +// SecrecyLabel.CheckFlow(target) returns true if all tags in source are also in target +// i.e., source ⊆ target (source is a subset of target) +func (source *SecrecyLabel) CheckFlow(target *SecrecyLabel) (bool, []Tag) + +// IntegrityLabel.CheckFlow(target) returns true if all tags in source are also in target +// i.e., source ⊆ target (source is a subset of target) +func (source *IntegrityLabel) CheckFlow(target *IntegrityLabel) (bool, []Tag) +``` + +**CRITICAL**: To check `A ⊇ B` (A contains all of B), call `B.CheckFlow(A)`. + +### Evaluator Functions + +The evaluator uses these `CheckFlow` calls to implement the DIFC rules: + +```go +// For READ access: +// Secrecy: A.secrecy ⊇ R.secrecy → resource.Secrecy.CheckFlow(agentSecrecy) +// Integrity: R.integrity ⊇ A.integrity → resource.Integrity.CheckFlow(agentIntegrity) + +// For WRITE access: +// Secrecy: R.secrecy ⊇ A.secrecy → agentSecrecy.CheckFlow(&resource.Secrecy) +// Integrity: A.integrity ⊇ R.integrity → agentIntegrity.CheckFlow(&resource.Integrity) +``` + +**Remember**: `X.CheckFlow(Y)` returns true when `X ⊆ Y` (all tags in X are in Y). +So to check `A ⊇ B`, call `B.CheckFlow(A)`. + +## Testing Guidelines + +When writing tests: + +1. Empty labels `{}` represent public/untrusted resources +2. To test secrecy violations, give the agent secrecy tags the resource lacks +3. To test integrity violations, give the agent integrity tags the resource lacks +4. For reads: agent needs clearance (secrecy), resource needs trust (integrity) +5. For writes: resource needs to accept secrets (secrecy), agent needs trust (integrity) diff --git a/internal/difc/evaluator.go b/internal/difc/evaluator.go index 3b2f7de8..9ba5d022 100644 --- a/internal/difc/evaluator.go +++ b/internal/difc/evaluator.go @@ -138,7 +138,8 @@ func (e *Evaluator) evaluateRead( } // For reads: agent must be able to handle resource's secrecy - // All resource secrecy tags must be present in agent secrecy + // Agent secrecy must be superset of resource secrecy (agent has clearance) + // Check: resource.Secrecy ⊆ agentSecrecy (all resource secrecy tags are in agent) ok, extraTags := resource.Secrecy.CheckFlow(agentSecrecy) if !ok { logEvaluator.Printf("Read denied: secrecy check failed, extraTags=%v", extraTags) @@ -183,14 +184,15 @@ func (e *Evaluator) evaluateWrite( } // For writes: agent secrecy must flow to resource secrecy - // All agent secrecy tags must be present in resource secrecy + // Resource secrecy must be superset of agent secrecy (no information leak) + // Check: agentSecrecy ⊆ resource.Secrecy (all agent secrecy tags are in resource) ok, extraTags := agentSecrecy.CheckFlow(&resource.Secrecy) if !ok { logEvaluator.Printf("Write denied: secrecy check failed, extraTags=%v", extraTags) result.Decision = AccessDeny result.SecrecyToAdd = extraTags result.Reason = fmt.Sprintf("Agent has secrecy tags %v that cannot flow to '%s'. "+ - "Agent would need resource to have these secrecy requirements too.", + "Resource would need these secrecy requirements to accept the write.", extraTags, resource.Description) return result } diff --git a/internal/difc/evaluator_test.go b/internal/difc/evaluator_test.go index 429ea5ad..57bd9720 100644 --- a/internal/difc/evaluator_test.go +++ b/internal/difc/evaluator_test.go @@ -133,9 +133,10 @@ func TestEvaluator_Evaluate_ReadWrite(t *testing.T) { t.Run("ReadWrite denied when read constraint fails", func(t *testing.T) { // Agent lacks secrecy tag needed to read + // Note: Agent should NOT have integrity tags that resource doesn't have, + // otherwise integrity check fails first (resource integrity must flow to agent) agentSecrecy := NewSecrecyLabel() - agentIntegrity := NewIntegrityLabel() - agentIntegrity.Label.Add("trusted") + agentIntegrity := NewIntegrityLabel() // No integrity requirements resource := NewLabeledResource("private-file") resource.Secrecy.Label.Add("private") @@ -176,9 +177,14 @@ func TestEvaluator_Evaluate_ReadWrite(t *testing.T) { agentIntegrity.Label.Add("verified") resource := NewLabeledResource("complex-resource") + // For READ: agent secrecy ⊇ resource secrecy (agent has clearance) resource.Secrecy.Label.Add("secret") resource.Secrecy.Label.Add("confidential") + resource.Secrecy.Label.Add("internal") // Resource must accept all agent secrecy for WRITE + // For READ: resource integrity ⊇ agent integrity (resource is trusted enough) + // For WRITE: agent integrity ⊇ resource integrity (agent is trusted enough) resource.Integrity.Label.Add("production") + resource.Integrity.Label.Add("verified") result := eval.Evaluate(agentSecrecy, agentIntegrity, resource, OperationReadWrite) @@ -581,8 +587,9 @@ func TestEvaluator_FilterCollection_Advanced(t *testing.T) { agentSecrecy := NewSecrecyLabel() agentSecrecy.Label.Add("public") agentSecrecy.Label.Add("internal") + // Note: Agent should NOT have integrity requirements if we want to test secrecy filtering + // on resources with no integrity tags (read check requires resource to have agent's integrity tags) agentIntegrity := NewIntegrityLabel() - agentIntegrity.Label.Add("trusted") collection := &CollectionLabeledData{ Items: []LabeledItem{ @@ -615,7 +622,7 @@ func TestEvaluator_FilterCollection_Advanced(t *testing.T) { Labels: &LabeledResource{ Description: "low integrity", Secrecy: *NewSecrecyLabelWithTags([]Tag{"public"}), - Integrity: *NewIntegrityLabel(), // No trusted tag + Integrity: *NewIntegrityLabel(), }, }, }, @@ -624,9 +631,8 @@ func TestEvaluator_FilterCollection_Advanced(t *testing.T) { filtered := eval.FilterCollection(agentSecrecy, agentIntegrity, collection, OperationRead) assert.Equal(t, 4, filtered.TotalCount) - // Items 0, 1, and 3 should be accessible (agent has required secrecy, integrity check should pass for reads) - // Actually for reads: agent integrity must be >= resource integrity (trust check) - // Since resource has empty integrity and agent has "trusted", agent is over-qualified + // Items 0, 1, and 3 should be accessible (agent has required secrecy tags) + // Item 2 (secret) should be filtered (agent lacks "secret" secrecy tag) assert.Equal(t, 3, filtered.GetAccessibleCount(), "public, internal, and low-integrity should be accessible") assert.Equal(t, 1, filtered.GetFilteredCount(), "secret should be filtered") }) diff --git a/internal/launcher/getorlaunch_stdio_test.go b/internal/launcher/getorlaunch_stdio_test.go index 5f96b5bd..d54c07e7 100644 --- a/internal/launcher/getorlaunch_stdio_test.go +++ b/internal/launcher/getorlaunch_stdio_test.go @@ -8,26 +8,20 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/githubnext/gh-aw-mcpg/internal/config" "github.com/githubnext/gh-aw-mcpg/internal/mcp" ) // TestGetOrLaunch_StdioServer_InvalidCommand tests stdio server with invalid command func TestGetOrLaunch_StdioServer_InvalidCommand(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "stdio-server": { - "type": "stdio", - "command": "nonexistent-command-12345", - "args": ["--flag"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "stdio-server": { + Type: "stdio", + Command: "nonexistent-command-12345", + Args: []string{"--flag"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -41,21 +35,14 @@ func TestGetOrLaunch_StdioServer_InvalidCommand(t *testing.T) { // TestGetOrLaunch_StdioServer_DockerCommand tests stdio server with Docker command func TestGetOrLaunch_StdioServer_DockerCommand(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "docker-server": { - "type": "stdio", - "command": "docker", - "args": ["run", "--rm", "-i", "test-image:latest"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "docker-server": { + Type: "stdio", + Command: "docker", + Args: []string{"run", "--rm", "-i", "test-image:latest"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -72,21 +59,14 @@ func TestGetOrLaunch_StdioServer_EnvPassthrough(t *testing.T) { t.Setenv("TEST_PASSTHROUGH_VAR", "test-value-123") t.Setenv("ANOTHER_VAR", "another-value-456") - jsonConfig := `{ - "mcpServers": { - "env-test-server": { - "type": "stdio", - "command": "echo", - "args": ["-e", "TEST_PASSTHROUGH_VAR", "-e", "ANOTHER_VAR", "hello"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "env-test-server": { + Type: "stdio", + Command: "echo", + Args: []string{"-e", "TEST_PASSTHROUGH_VAR", "-e", "ANOTHER_VAR", "hello"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -101,21 +81,14 @@ func TestGetOrLaunch_StdioServer_EnvPassthrough(t *testing.T) { // TestGetOrLaunch_StdioServer_EnvPassthroughMissing tests missing env var passthrough func TestGetOrLaunch_StdioServer_EnvPassthroughMissing(t *testing.T) { // Do NOT set MISSING_VAR - testing the warning path - jsonConfig := `{ - "mcpServers": { - "missing-env-server": { - "type": "stdio", - "command": "echo", - "args": ["-e", "MISSING_VAR", "test"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "missing-env-server": { + Type: "stdio", + Command: "echo", + Args: []string{"-e", "MISSING_VAR", "test"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -128,21 +101,14 @@ func TestGetOrLaunch_StdioServer_EnvPassthroughMissing(t *testing.T) { // TestGetOrLaunch_StdioServer_EnvExplicitValue tests -e flag with explicit value func TestGetOrLaunch_StdioServer_EnvExplicitValue(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "explicit-env-server": { - "type": "stdio", - "command": "echo", - "args": ["-e", "VAR=explicit_value", "test"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "explicit-env-server": { + Type: "stdio", + Command: "echo", + Args: []string{"-e", "VAR=explicit_value", "test"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -159,21 +125,14 @@ func TestGetOrLaunch_StdioServer_EnvLongValue(t *testing.T) { longValue := "this-is-a-very-long-value-that-should-be-truncated-in-logs" t.Setenv("LONG_VAR", longValue) - jsonConfig := `{ - "mcpServers": { - "long-env-server": { - "type": "stdio", - "command": "echo", - "args": ["-e", "LONG_VAR", "test"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "long-env-server": { + Type: "stdio", + Command: "echo", + Args: []string{"-e", "LONG_VAR", "test"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -190,21 +149,14 @@ func TestGetOrLaunch_StdioServer_MultipleEnvFlags(t *testing.T) { t.Setenv("VAR2", "value2") t.Setenv("VAR3", "value3") - jsonConfig := `{ - "mcpServers": { - "multi-env-server": { - "type": "stdio", - "command": "echo", - "args": ["-e", "VAR1", "-e", "VAR2", "-e", "VAR3", "test"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "multi-env-server": { + Type: "stdio", + Command: "echo", + Args: []string{"-e", "VAR1", "-e", "VAR2", "-e", "VAR3", "test"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -217,21 +169,14 @@ func TestGetOrLaunch_StdioServer_MultipleEnvFlags(t *testing.T) { // TestGetOrLaunch_StdioServer_EnvFlagAtEnd tests -e flag at end of args (no value) func TestGetOrLaunch_StdioServer_EnvFlagAtEnd(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "env-at-end-server": { - "type": "stdio", - "command": "echo", - "args": ["test", "-e"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "env-at-end-server": { + Type: "stdio", + Command: "echo", + Args: []string{"test", "-e"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -244,25 +189,18 @@ func TestGetOrLaunch_StdioServer_EnvFlagAtEnd(t *testing.T) { // TestGetOrLaunch_StdioServer_WithEnvMap tests stdio server with env map func TestGetOrLaunch_StdioServer_WithEnvMap(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "env-map-server": { - "type": "stdio", - "command": "echo", - "args": ["test"], - "env": { - "CUSTOM_VAR": "custom-value", - "API_KEY": "secret-key-12345678" - } - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "env-map-server": { + Type: "stdio", + Command: "echo", + Args: []string{"test"}, + Env: map[string]string{ + "CUSTOM_VAR": "custom-value", + "API_KEY": "secret-key-12345678", + }, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -275,22 +213,15 @@ func TestGetOrLaunch_StdioServer_WithEnvMap(t *testing.T) { // TestGetOrLaunch_StdioServer_EmptyEnvMap tests empty env map func TestGetOrLaunch_StdioServer_EmptyEnvMap(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "empty-env-server": { - "type": "stdio", - "command": "echo", - "args": ["test"], - "env": {} - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "empty-env-server": { + Type: "stdio", + Command: "echo", + Args: []string{"test"}, + Env: map[string]string{}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -303,21 +234,14 @@ func TestGetOrLaunch_StdioServer_EmptyEnvMap(t *testing.T) { // TestGetOrLaunch_DirectCommandInContainer tests direct command in container detection func TestGetOrLaunch_DirectCommandInContainer(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "direct-command-server": { - "type": "stdio", - "command": "python", - "args": ["-m", "server"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "direct-command-server": { + Type: "stdio", + Command: "python", + Args: []string{"-m", "server"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -335,21 +259,14 @@ func TestGetOrLaunch_DirectCommandInContainer(t *testing.T) { // TestGetOrLaunch_DockerCommandInContainer tests Docker command in container (no warning) func TestGetOrLaunch_DockerCommandInContainer(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "docker-in-container": { - "type": "stdio", - "command": "docker", - "args": ["run", "-i", "test:latest"] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "docker-in-container": { + Type: "stdio", + Command: "docker", + Args: []string{"run", "-i", "test:latest"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -366,20 +283,13 @@ func TestGetOrLaunch_DockerCommandInContainer(t *testing.T) { // TestGetOrLaunch_ConcurrentLaunch tests concurrent launches of same server (double-check lock) func TestGetOrLaunch_ConcurrentLaunch(t *testing.T) { // Use HTTP server since stdio servers actually launch processes - jsonConfig := `{ - "mcpServers": { - "concurrent-server": { - "type": "http", - "url": "http://nonexistent.local" - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "concurrent-server": { + Type: "http", + URL: "http://nonexistent.local", }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -433,20 +343,13 @@ func TestGetOrLaunch_ConcurrentLaunch(t *testing.T) { // TestGetOrLaunch_RaceConditionDoubleCheck tests the double-check locking pattern func TestGetOrLaunch_RaceConditionDoubleCheck(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "race-test-server": { - "type": "http", - "url": "http://localhost:9999" - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "race-test-server": { + Type: "http", + URL: "http://localhost:9999", }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -492,20 +395,13 @@ func TestGetOrLaunch_RaceConditionDoubleCheck(t *testing.T) { // TestGetOrLaunch_HTTPConnectionError tests HTTP connection creation failure func TestGetOrLaunch_HTTPConnectionError(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "bad-http-server": { - "type": "http", - "url": "://invalid-url-format" - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "bad-http-server": { + Type: "http", + URL: "://invalid-url-format", }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -524,21 +420,14 @@ func TestGetOrLaunch_HTTPConnectionError(t *testing.T) { // TestGetOrLaunch_StdioConnectionError tests stdio connection creation failure func TestGetOrLaunch_StdioConnectionError(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "bad-stdio-server": { - "type": "stdio", - "command": "/nonexistent/path/to/binary", - "args": [] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "bad-stdio-server": { + Type: "stdio", + Command: "/nonexistent/path/to/binary", + Args: []string{}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -557,22 +446,15 @@ func TestGetOrLaunch_StdioConnectionError(t *testing.T) { // TestGetOrLaunch_ErrorLogging_DirectCommand tests error logging for direct command func TestGetOrLaunch_ErrorLogging_DirectCommand(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "error-logging-server": { - "type": "stdio", - "command": "nonexistent-binary", - "args": ["--test"], - "env": {"TEST": "value"} - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "error-logging-server": { + Type: "stdio", + Command: "nonexistent-binary", + Args: []string{"--test"}, + Env: map[string]string{"TEST": "value"}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -585,21 +467,14 @@ func TestGetOrLaunch_ErrorLogging_DirectCommand(t *testing.T) { // TestGetOrLaunch_ErrorLogging_DirectCommandInContainer tests error logging for direct command in container func TestGetOrLaunch_ErrorLogging_DirectCommandInContainer(t *testing.T) { - jsonConfig := `{ - "mcpServers": { - "error-container-server": { - "type": "stdio", - "command": "missing-command", - "args": [] - } + cfg := newTestConfig(map[string]*config.ServerConfig{ + "error-container-server": { + Type: "stdio", + Command: "missing-command", + Args: []string{}, }, - "gateway": { - "port": 3001, - "domain": "localhost" - } - }` + }) - cfg := loadConfigFromJSON(t, jsonConfig) ctx := context.Background() l := New(ctx, cfg) defer l.Close() @@ -624,7 +499,8 @@ func TestGetOrLaunch_ContainerFieldConversion(t *testing.T) { }, "gateway": { "port": 3001, - "domain": "localhost" + "domain": "localhost", + "apiKey": "test-api-key" } }` diff --git a/internal/launcher/launcher_test.go b/internal/launcher/launcher_test.go index 164c01bc..bccf8517 100644 --- a/internal/launcher/launcher_test.go +++ b/internal/launcher/launcher_test.go @@ -17,6 +17,8 @@ import ( ) // loadConfigFromJSON is a test helper that creates a config from JSON via stdin +// Note: This validates against the JSON schema, so configs must match the schema. +// For tests that need invalid/non-schema configs, use newTestConfig instead. func loadConfigFromJSON(t *testing.T, jsonConfig string) *config.Config { t.Helper() @@ -38,6 +40,19 @@ func loadConfigFromJSON(t *testing.T, jsonConfig string) *config.Config { return cfg } +// newTestConfig creates a config directly without going through JSON parsing/schema validation. +// Use this for unit tests that need to test launcher behavior with non-standard command configurations +// that don't match the schema (e.g., testing with command="echo" instead of container images). +func newTestConfig(servers map[string]*config.ServerConfig) *config.Config { + return &config.Config{ + Servers: servers, + Gateway: &config.GatewayConfig{ + Port: 3001, + Domain: "localhost", + }, + } +} + func TestHTTPConnection(t *testing.T) { tests := []struct { name string