diff --git a/docs/TOOLGATE.md b/docs/TOOLGATE.md index 085eb44..d8d527b 100644 --- a/docs/TOOLGATE.md +++ b/docs/TOOLGATE.md @@ -23,7 +23,13 @@ The study behind the component compared three configurations over the same twelv | `["*"]` | passes the gateway; the policy's own grant then applies unchanged | 2. **Values that cannot be checked are refused.** A rule whose argument is absent, is not a number where a number is required, or carries more than one value in one argument (`"a@evil.example,b@corp.example"`) fails closed rather than being skipped. Such a decision reports `cannot verify: ...` rather than the rule's own message, so an approver is not told a bank account changed when the call never mentioned one. -2. **Rules.** In `ModeFull`, argument-value rules are evaluated in policy order and the first that fires decides: `ceiling` (numeric maximum), `allowlist`, `lookup` (the value must match what a table maps another argument to, such as the vendor account on file for this invoice), `sensitive_field` (escalate to a person), `suffix` (email domains), `prefix` (storage destinations). A rule's `effect` is `deny` or `escalate`. +2. **Rules.** In `ModeFull`, argument-value rules are evaluated in policy order and the first that fires decides: `ceiling` (numeric maximum), `allowlist`, `lookup` (the value must match what a table maps another argument to, such as the vendor account on file for this invoice), `sensitive_field` (escalate to a person), `suffix` (email domains), `prefix` (storage destinations), `tool` (the action itself, whatever the arguments). A rule's `effect` is `deny` or `escalate`. + + **More value checks.** `floor` (numeric minimum, `min`), `denylist` (`denied` values, compared without regard to case), `pattern` (`pattern`, an RE2 expression the whole value must match; RE2 cannot be driven into exponential time), `domain` (the host of an email address, a URL or a bare host name must be one of `allowed` or a subdomain of one; parsed, so `https://corp.example@evil.example` reads as `evil.example`; several `@` or a non-ASCII host fail closed), `max_items` (at most `max` items in a list; a lone value counts as one), and `compare` (`arg` `op` `match_arg` must hold, `op` one of `lt`, `le`, `gt`, `ge` on numbers, `eq`, `ne` on numbers or plain values, e.g. a refund may not exceed the original charge). + + **Lists and nested arguments.** `each: true` applies a single-value rule (ceiling, floor, allowlist, denylist, suffix, prefix, pattern, domain, sensitive_field) to every element of a list argument and to a lone value as a list of one; without it a list still fails closed. `arg` and `match_arg` may be paths: `payment.amount` reads a field of an object argument, `items[*].amount` the field of every element of a list, each of which is checked. An argument literally named with a dot is found by its exact name first. A path that does not exist in the call, or runs into a value of the wrong shape, fails closed like an absent argument. + + **Tool tags.** A policy can label tools with classes in `tool_tags` (`{"delete_file": ["destructive"], "payout": ["money"]}`), and a rule can name a `tag` instead of a `tool`, so one rule governs every tool carrying the tag: `{"id": "T1", "tag": "destructive", "kind": "tool", "effect": "escalate"}` holds every destructive action for a person, and `{"id": "T2", "tag": "money", "kind": "ceiling", "arg": "amount", "max": 100}` caps every money tool. A rule sets `tool` or `tag`, not both. Validation refuses a tag on a tool missing from `tool_scopes`, a rule on a tag no tool carries, and a `tool` rule with an `arg` or with neither a tool nor a tag, since each would leave a tool silently ungoverned or a rule firing everywhere. Argument rules scoped by tag keep the fail-closed rule for absent arguments: a tagged tool must supply the argument its tag's rule checks. 3. **Execution.** Allowed calls run through the caller's executor; refused calls do not run; escalated calls are held until `Resolve` records a human decision under the approver's identity. 4. **Artifact.** One record per decision, chained to the previous record for the provider. 5. **Events.** `toolcall.requested`, `toolcall.decided`, then `toolcall.executed`, `toolcall.refused` or `toolcall.escalated`; a resolved hold adds `toolcall.approved` (or `toolcall.refused`). The AEX `events.Publisher` satisfies the gate's `Publisher` interface, so records land in JetStream with the standard envelope. @@ -78,6 +84,7 @@ agent -> aex-gateway -> aex-toolgate -> provider tools | Endpoint | What it does | |---|---| | `POST /v1/tools/{tool}` | body: the argument values as a JSON object. The gate decides; an allowed call is forwarded to `UPSTREAM_URL` + `UPSTREAM_PREFIX` + `/{tool}` and the tool's answer is returned with `X-Toolgate-Decision`, `X-Toolgate-Rule` and `X-Toolgate-Hash`. A refused call answers 403 with the rule and the message; a held call answers 202 with the hold hash. | +| `POST /v1/decide/{tool}` | **operator only.** Same body and identity headers as `/v1/tools/{tool}`. The gate decides and records, but forwards nothing and holds nothing; the artifact's outcome is `decided only: not executed by the gate`, and only `toolcall.requested` and `toolcall.decided` are published. Answers 200 with `decision`, `rule`, `scope`, `approval`, `message`, `outcome`, `hash`, `call_id` (and the `X-Toolgate-*` headers). For callers that execute tools themselves, such as a benchmark harness, or for measuring a policy in shadow before it enforces. Operator-only because a gated agent that can ask what would pass, without consequence, can search the policy for values that slip through. | | `POST /v1/holds/{hash}` | **operator only.** Body `{"approver": "...", "approved": true}`. Settles a held call: runs the tool when approved, appends the human decision to the chain, publishes `toolcall.approved` or `toolcall.refused`. | | `GET /v1/records`, `GET /v1/records/verify` | **operator only.** The provider's chain and its verification. | | `GET /health`, `GET /ready` | probes | diff --git a/src/aex-bid-evaluator/go.mod b/src/aex-bid-evaluator/go.mod index d5b5774..019fc8a 100644 --- a/src/aex-bid-evaluator/go.mod +++ b/src/aex-bid-evaluator/go.mod @@ -45,12 +45,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-bid-evaluator/go.sum b/src/aex-bid-evaluator/go.sum index 18c975e..2457c4f 100644 --- a/src/aex-bid-evaluator/go.sum +++ b/src/aex-bid-evaluator/go.sum @@ -87,6 +87,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -118,6 +120,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-bid-gateway/go.mod b/src/aex-bid-gateway/go.mod index 68402e9..d652665 100644 --- a/src/aex-bid-gateway/go.mod +++ b/src/aex-bid-gateway/go.mod @@ -45,12 +45,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-bid-gateway/go.sum b/src/aex-bid-gateway/go.sum index 18c975e..2457c4f 100644 --- a/src/aex-bid-gateway/go.sum +++ b/src/aex-bid-gateway/go.sum @@ -87,6 +87,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -118,6 +120,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-certauth/go.mod b/src/aex-certauth/go.mod index f7a41f2..b93acae 100644 --- a/src/aex-certauth/go.mod +++ b/src/aex-certauth/go.mod @@ -54,12 +54,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-certauth/go.sum b/src/aex-certauth/go.sum index e03a8ba..549206b 100644 --- a/src/aex-certauth/go.sum +++ b/src/aex-certauth/go.sum @@ -93,6 +93,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -124,6 +126,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-contract-engine/go.mod b/src/aex-contract-engine/go.mod index 135951b..e453549 100644 --- a/src/aex-contract-engine/go.mod +++ b/src/aex-contract-engine/go.mod @@ -45,12 +45,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-contract-engine/go.sum b/src/aex-contract-engine/go.sum index 18c975e..2457c4f 100644 --- a/src/aex-contract-engine/go.sum +++ b/src/aex-contract-engine/go.sum @@ -87,6 +87,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -118,6 +120,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-credentials-provider/go.mod b/src/aex-credentials-provider/go.mod index ed7976a..fd1cdff 100644 --- a/src/aex-credentials-provider/go.mod +++ b/src/aex-credentials-provider/go.mod @@ -35,11 +35,11 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-credentials-provider/go.sum b/src/aex-credentials-provider/go.sum index c31227b..26b96fb 100644 --- a/src/aex-credentials-provider/go.sum +++ b/src/aex-credentials-provider/go.sum @@ -57,10 +57,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -69,6 +75,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-gateway/go.mod b/src/aex-gateway/go.mod index 02e0640..d9d0a6c 100644 --- a/src/aex-gateway/go.mod +++ b/src/aex-gateway/go.mod @@ -36,11 +36,11 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-gateway/go.sum b/src/aex-gateway/go.sum index ffb4cd9..7fff254 100644 --- a/src/aex-gateway/go.sum +++ b/src/aex-gateway/go.sum @@ -73,10 +73,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -85,6 +91,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-identity/go.mod b/src/aex-identity/go.mod index 2d73443..61db72f 100644 --- a/src/aex-identity/go.mod +++ b/src/aex-identity/go.mod @@ -41,12 +41,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-identity/go.sum b/src/aex-identity/go.sum index 647e143..08e93f5 100644 --- a/src/aex-identity/go.sum +++ b/src/aex-identity/go.sum @@ -82,6 +82,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -113,6 +115,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-provider-registry/go.mod b/src/aex-provider-registry/go.mod index 16797b3..7ed6d8e 100644 --- a/src/aex-provider-registry/go.mod +++ b/src/aex-provider-registry/go.mod @@ -41,12 +41,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-provider-registry/go.sum b/src/aex-provider-registry/go.sum index 647e143..08e93f5 100644 --- a/src/aex-provider-registry/go.sum +++ b/src/aex-provider-registry/go.sum @@ -82,6 +82,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -113,6 +115,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-settlement/go.mod b/src/aex-settlement/go.mod index 0bca340..5487ebd 100644 --- a/src/aex-settlement/go.mod +++ b/src/aex-settlement/go.mod @@ -54,12 +54,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-settlement/go.sum b/src/aex-settlement/go.sum index 1139d39..3b0123f 100644 --- a/src/aex-settlement/go.sum +++ b/src/aex-settlement/go.sum @@ -90,6 +90,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -121,6 +123,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-telemetry/go.mod b/src/aex-telemetry/go.mod index dc7c4d6..9222e2b 100644 --- a/src/aex-telemetry/go.mod +++ b/src/aex-telemetry/go.mod @@ -28,12 +28,12 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-telemetry/go.sum b/src/aex-telemetry/go.sum index c31227b..26b96fb 100644 --- a/src/aex-telemetry/go.sum +++ b/src/aex-telemetry/go.sum @@ -57,10 +57,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -69,6 +75,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-token-bank/go.mod b/src/aex-token-bank/go.mod index 6e35fb7..0db070d 100644 --- a/src/aex-token-bank/go.mod +++ b/src/aex-token-bank/go.mod @@ -30,12 +30,12 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-token-bank/go.sum b/src/aex-token-bank/go.sum index c31227b..26b96fb 100644 --- a/src/aex-token-bank/go.sum +++ b/src/aex-token-bank/go.sum @@ -57,10 +57,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -69,6 +75,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-toolgate/internal/httpapi/decide_test.go b/src/aex-toolgate/internal/httpapi/decide_test.go new file mode 100644 index 0000000..90eb4f7 --- /dev/null +++ b/src/aex-toolgate/internal/httpapi/decide_test.go @@ -0,0 +1,75 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/parlakisik/agent-exchange/internal/toolgate" +) + +// The twelve calls through /v1/decide reach the same decisions as through +// /v1/tools, but nothing is forwarded, nothing is held, and every record says +// the gate did not act. +func TestDecideOnlyRulesWithoutExecuting(t *testing.T) { + srv, upstream, calls, executed := setup(t) + defer srv.Close() + defer upstream.Close() + + want := map[string]string{ + "C01": "allow", "C02": "allow", "C03": "allow", "C04": "allow", "C05": "deny", "C06": "deny", + "C07": "escalate", "C08": "allow", "C09": "deny", "C10": "deny", "C11": "deny", "C12": "deny", + } + var heldHash string + for _, c := range calls { + resp, body := post(t, srv.URL+"/v1/decide/"+c.Tool, c.Args, + map[string]string{"Authorization": "Bearer " + testOperatorToken, "X-AEX-Call-ID": c.ID}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s: got %d, want 200 whatever the decision", c.ID, resp.StatusCode) + } + if got := body["decision"]; got != want[c.ID] || resp.Header.Get("X-Toolgate-Decision") != want[c.ID] { + t.Errorf("%s: decision %v (header %q), want %s", c.ID, got, resp.Header.Get("X-Toolgate-Decision"), want[c.ID]) + } + if body["outcome"] != toolgate.OutcomeDecideOnly { + t.Errorf("%s: outcome %v, want the decide-only outcome", c.ID, body["outcome"]) + } + if c.ID == "C07" { + heldHash, _ = body["hash"].(string) + } + } + if len(*executed) != 0 { + t.Errorf("decide-only forwarded calls to the tool: %v", *executed) + } + + // The escalated call left no hold behind to settle. + resp, _ := post(t, srv.URL+"/v1/holds/"+heldHash, map[string]any{"approver": "user:x", "approved": true}, operatorHeaders()) + if resp.StatusCode == http.StatusOK { + t.Error("a decide-only escalation must not create a hold") + } + + _, raw := getAs(t, srv.URL+"/v1/records/verify", operatorHeaders()) + var v map[string]any + _ = json.Unmarshal(raw, &v) + if v["ok"] != true || v["records"] != float64(12) { + t.Errorf("chain: %v, want ok with 12 records", v) + } +} + +// Decide-only is an operator endpoint: the gated agent must not be able to ask +// the gate what would pass. +func TestDecideOnlyRefusesTheGatedAgent(t *testing.T) { + srv, upstream, _, _ := setup(t) + defer srv.Close() + defer upstream.Close() + + resp, _ := post(t, srv.URL+"/v1/decide/send_payment", map[string]any{"invoice_id": "INV-1042", "amount": 1.0}, nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("no credential: got %d, want 401", resp.StatusCode) + } + _, raw := getAs(t, srv.URL+"/v1/records/verify", operatorHeaders()) + var v map[string]any + _ = json.Unmarshal(raw, &v) + if v["records"] != float64(0) { + t.Errorf("a refused decide request must leave no record, got %v", v["records"]) + } +} diff --git a/src/aex-toolgate/internal/httpapi/router.go b/src/aex-toolgate/internal/httpapi/router.go index 90d5f95..0dff5e4 100644 --- a/src/aex-toolgate/internal/httpapi/router.go +++ b/src/aex-toolgate/internal/httpapi/router.go @@ -24,8 +24,13 @@ // Those headers are attribution, not authentication: they are whatever the // caller sent, and none of them widens an authorization decision. // -// The operator endpoints (POST /v1/holds/{hash}, GET /v1/records and -// /v1/records/verify) are outside that model. They belong to the provider's +// POST /v1/decide/{tool} takes the same body and headers as /v1/tools/{tool} +// but only decides and records: nothing is forwarded, nothing is held. It is an +// operator endpoint, because an agent that can ask "would this pass?" without +// consequence can search the policy for values that slip through. +// +// The operator endpoints (POST /v1/decide/{tool}, POST /v1/holds/{hash}, GET +// /v1/records and /v1/records/verify) are outside that model. They belong to the provider's // operator, not to the agent being gated, and require // Authorization: Bearer . With no token configured they are // refused rather than left open. @@ -87,6 +92,7 @@ func New(gate *toolgate.Gate, upstreamURL, upstreamPrefix string, timeout time.D mux.HandleFunc("GET /health", s.health) mux.HandleFunc("GET /ready", s.health) mux.HandleFunc("POST /v1/tools/{tool}", s.authorize) + mux.HandleFunc("POST /v1/decide/{tool}", s.decide) mux.HandleFunc("POST /v1/holds/{hash}", s.resolve) mux.HandleFunc("GET /v1/records", s.records) mux.HandleFunc("GET /v1/records/verify", s.verify) @@ -111,25 +117,9 @@ func (s *Server) authorize(w http.ResponseWriter, r *http.Request) { r.Header.Set("X-Request-ID", newRequestID()) } w.Header().Set("X-Request-ID", r.Header.Get("X-Request-ID")) - var args map[string]any - if r.Body != nil { - b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - writeError(w, http.StatusBadRequest, "bad_request", "cannot read body") - return - } - if len(bytes.TrimSpace(b)) > 0 { - // UseNumber keeps integers exact. Decoding into float64 rewrites - // anything past 2^53 (an account or invoice number), so the tool - // would receive a different value than the agent sent and the - // artifact would record the rewritten one. - dec := json.NewDecoder(bytes.NewReader(b)) - dec.UseNumber() - if err := dec.Decode(&args); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", "body must be a JSON object of argument values") - return - } - } + args, ok := readArgs(w, r) + if !ok { + return } call := callFromRequest(r, tool, args) @@ -174,6 +164,58 @@ func (s *Server) authorize(w http.ResponseWriter, r *http.Request) { } } +// decide rules on a call and records it without forwarding or holding it. The +// answer is 200 whatever the decision: the request succeeded, and the decision +// is its content (also in the X-Toolgate-* headers, as on /v1/tools). +func (s *Server) decide(w http.ResponseWriter, r *http.Request) { + if !s.requireOperator(w, r) { + return + } + if r.Header.Get("X-Request-ID") == "" { + r.Header.Set("X-Request-ID", newRequestID()) + } + w.Header().Set("X-Request-ID", r.Header.Get("X-Request-ID")) + args, ok := readArgs(w, r) + if !ok { + return + } + call := callFromRequest(r, r.PathValue("tool"), args) + a := s.gate.DecideOnly(r.Context(), call) + w.Header().Set("X-Toolgate-Decision", a.Decision) + w.Header().Set("X-Toolgate-Rule", a.Rule) + w.Header().Set("X-Toolgate-Hash", a.Hash) + writeJSON(w, http.StatusOK, map[string]any{"decision": a.Decision, "rule": a.Rule, "scope": a.Scope, + "approval": a.Approval, "message": s.gate.Decide(call).Message, "outcome": a.Outcome, + "hash": a.Hash, "call_id": a.CallID}) +} + +// readArgs decodes the argument values from the request body, answering 400 +// itself when it cannot. +func readArgs(w http.ResponseWriter, r *http.Request) (map[string]any, bool) { + var args map[string]any + if r.Body == nil { + return args, true + } + b, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "cannot read body") + return nil, false + } + if len(bytes.TrimSpace(b)) > 0 { + // UseNumber keeps integers exact. Decoding into float64 rewrites + // anything past 2^53 (an account or invoice number), so the tool + // would receive a different value than the agent sent and the + // artifact would record the rewritten one. + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + if err := dec.Decode(&args); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "body must be a JSON object of argument values") + return nil, false + } + } + return args, true +} + // forward sends the allowed call to the upstream tool endpoint. func (s *Server) forward(ctx context.Context, in *http.Request, c toolgate.Call) (upstreamResult, error) { body, _ := json.Marshal(c.Args) diff --git a/src/aex-trust-broker/go.mod b/src/aex-trust-broker/go.mod index 6ceacc3..6562e09 100644 --- a/src/aex-trust-broker/go.mod +++ b/src/aex-trust-broker/go.mod @@ -41,12 +41,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-trust-broker/go.sum b/src/aex-trust-broker/go.sum index 647e143..08e93f5 100644 --- a/src/aex-trust-broker/go.sum +++ b/src/aex-trust-broker/go.sum @@ -82,6 +82,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -113,6 +115,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/aex-work-publisher/go.mod b/src/aex-work-publisher/go.mod index c60894e..6b32f4a 100644 --- a/src/aex-work-publisher/go.mod +++ b/src/aex-work-publisher/go.mod @@ -66,7 +66,7 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect @@ -75,6 +75,6 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/aex-work-publisher/go.sum b/src/aex-work-publisher/go.sum index f154bb0..253f3bc 100644 --- a/src/aex-work-publisher/go.sum +++ b/src/aex-work-publisher/go.sum @@ -126,6 +126,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -165,6 +167,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/internal/telemetry/go.mod b/src/internal/telemetry/go.mod index 7735671..9a0dc1b 100644 --- a/src/internal/telemetry/go.mod +++ b/src/internal/telemetry/go.mod @@ -29,11 +29,11 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/src/internal/telemetry/go.sum b/src/internal/telemetry/go.sum index c31227b..26b96fb 100644 --- a/src/internal/telemetry/go.sum +++ b/src/internal/telemetry/go.sum @@ -57,10 +57,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -69,6 +75,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/internal/toolgate/gate.go b/src/internal/toolgate/gate.go index a6bd268..6da5fd7 100644 --- a/src/internal/toolgate/gate.go +++ b/src/internal/toolgate/gate.go @@ -141,6 +141,9 @@ func (g *Gate) Decide(call Call) Decision { return Decision{Outcome: DecisionAllow, Rule: ScopeRuleID, Scope: scope, Approval: ApprovalAutomatic} } for _, r := range g.policy.Rules { + if !g.policy.applies(r, call.Tool) { + continue + } var fired bool var effect, reason string if r.Kind == KindLookup { @@ -214,6 +217,27 @@ func (g *Gate) Authorize(ctx context.Context, call Call, exec Executor) (Artifac return a, execErr } +// OutcomeDecideOnly is the outcome of a call decided by DecideOnly: the gate +// ruled and recorded, and the caller, not the gate, runs or skips the tool. +const OutcomeDecideOnly = "decided only: not executed by the gate" + +// DecideOnly decides the call and records the artifact without executing it +// and without holding it. It is for callers that execute tools themselves and +// ask the gate only for its ruling: a benchmark harness, or a policy measured +// in shadow before it enforces. The artifact's outcome says the gate did not +// act, so the record never reads as an enforced refusal or execution; an +// escalated call is recorded as escalated, but no hold exists to settle. +// Only toolcall.requested and toolcall.decided are published. +func (g *Gate) DecideOnly(ctx context.Context, call Call) Artifact { + d := g.Decide(call) + a := g.newArtifact(call, d) + a.Outcome = OutcomeDecideOnly + a = g.commit(a) + g.publish(ctx, EventRequested, a) + g.publish(ctx, EventDecided, a) + return a +} + // Resolve settles a held call. approver is the identity of the person who // decided; approved true runs the executor. A second artifact is appended so // the chain carries both the hold and the human decision. diff --git a/src/internal/toolgate/policy.go b/src/internal/toolgate/policy.go index bd37c44..b0d68b8 100644 --- a/src/internal/toolgate/policy.go +++ b/src/internal/toolgate/policy.go @@ -3,8 +3,11 @@ package toolgate import ( "encoding/json" "fmt" + "net/url" "os" + "regexp" "strings" + "sync" ) // Rule kinds. Rules are evaluated in the order they appear in the policy, @@ -24,6 +27,29 @@ const ( // KindSuffix denies when the string argument Arg does not end with one of // Allowed. Used for email domains. KindSuffix = "suffix" + // KindTool fires whenever the rule applies, whatever the arguments: the + // action itself is what the rule governs ("every destructive tool needs a + // person"). It takes no Arg and must be limited by Tool or Tag, so it can + // never fire on every tool by accident. + KindTool = "tool" + // KindFloor denies when the numeric argument Arg is below Min. + KindFloor = "floor" + // KindDenylist denies when the argument Arg is one of Denied (compared + // without regard to case, so a denied account cannot be dodged by case). + KindDenylist = "denylist" + // KindPattern denies when the argument Arg does not match Pattern in full. + // Patterns are RE2, so a pattern cannot be made to run in exponential time. + KindPattern = "pattern" + // KindDomain denies when the host of the argument Arg (an email address, a + // URL or a bare host name) is not one of Allowed or a subdomain of one. + KindDomain = "domain" + // KindMaxItems denies when the argument Arg holds more than Max items (a + // lone value counts as one): bulk sends, bulk deletes. + KindMaxItems = "max_items" + // KindCompare denies unless Arg Op MatchArg holds: lt, le, gt, ge on + // numbers, eq and ne on numbers or plain values ("a refund may not exceed + // the original charge"). + KindCompare = "compare" // KindPrefix denies when the string argument Arg does not start with one of // Allowed. Used for storage destinations. KindPrefix = "prefix" @@ -35,11 +61,14 @@ const ( EffectEscalate = "escalate" ) -// Rule is one argument-value rule. Tool restricts the rule to one tool; an -// empty Tool applies it to every tool that carries the argument. +// Rule is one argument-value rule. Tool restricts the rule to one tool and Tag +// to the tools the policy's ToolTags label with that tag; a rule sets at most +// one of them. An empty Tool and Tag applies it to every tool that carries the +// argument. type Rule struct { ID string `json:"id"` Tool string `json:"tool,omitempty"` + Tag string `json:"tag,omitempty"` Kind string `json:"kind"` Arg string `json:"arg"` Max float64 `json:"max,omitempty"` @@ -48,8 +77,32 @@ type Rule struct { MatchArg string `json:"match_arg,omitempty"` Effect string `json:"effect,omitempty"` Message string `json:"message,omitempty"` + // Min is the floor's lower bound; a pointer so that a floor of 0 is + // distinguishable from a floor nobody set. + Min *float64 `json:"min,omitempty"` + Denied []string `json:"denied,omitempty"` + Pattern string `json:"pattern,omitempty"` + // Op is the relation a compare rule requires between Arg and MatchArg. + Op string `json:"op,omitempty"` + // Each applies a value rule to every element of a list argument. Without + // it a list cannot be checked as one value and the rule fails closed. + Each bool `json:"each,omitempty"` } +// Arg (and MatchArg) name an argument, or a path into one: "recipient.email" +// reads a field of an object argument, and "items[*].amount" the field of +// every element of a list, each of which the rule then checks. An argument +// whose own name contains a dot is still found by its exact name first. + +// eachKinds are the kinds that check one value at a time and so can apply to +// each element of a list. +var eachKinds = map[string]bool{ + KindCeiling: true, KindFloor: true, KindAllowlist: true, KindDenylist: true, KindSuffix: true, + KindPrefix: true, KindPattern: true, KindDomain: true, KindSensitiveField: true, +} + +var compareOps = map[string]bool{"lt": true, "le": true, "gt": true, "ge": true, "eq": true, "ne": true} + // Policy is a provider's authorization policy: the scopes this session holds, // the scope each tool requires, the argument-value rules, and any lookup // tables the lookup rules read. @@ -59,6 +112,27 @@ type Policy struct { ToolScopes map[string]string `json:"tool_scopes"` Rules []Rule `json:"rules"` Lookups map[string]map[string]string `json:"lookups,omitempty"` + // ToolTags labels tools with classes ("destructive", "payment", ...) so + // one rule can govern many tools. Every tagged tool must be in ToolScopes. + ToolTags map[string][]string `json:"tool_tags,omitempty"` +} + +// hasTag reports whether the policy labels tool with tag. +func (p Policy) hasTag(tool, tag string) bool { + for _, t := range p.ToolTags[tool] { + if t == tag { + return true + } + } + return false +} + +// applies reports whether rule r governs calls to tool. +func (p Policy) applies(r Rule, tool string) bool { + if r.Tool != "" && r.Tool != tool { + return false + } + return r.Tag == "" || p.hasTag(tool, r.Tag) } // ScopeRuleID is the rule id recorded when the scope check decides a call. @@ -85,16 +159,95 @@ func (p Policy) Validate() error { if len(p.ToolScopes) == 0 { return fmt.Errorf("toolgate: policy maps no tools to scopes") } + tags := map[string]bool{} + for tool, ts := range p.ToolTags { + // A tag on a tool the policy does not know is a typo that would + // silently leave the real tool ungoverned. + if _, ok := p.ToolScopes[tool]; !ok { + return fmt.Errorf("toolgate: tool_tags names %q, which is not in tool_scopes", tool) + } + for _, t := range ts { + tags[t] = true + } + } seen := map[string]bool{} for _, r := range p.Rules { - if r.ID == "" || r.Arg == "" { - return fmt.Errorf("toolgate: rule %q needs an id and an arg", r.ID) + if r.ID == "" { + return fmt.Errorf("toolgate: rule needs an id") } if seen[r.ID] { return fmt.Errorf("toolgate: duplicate rule id %q", r.ID) } seen[r.ID] = true + if r.Tool != "" && r.Tag != "" { + return fmt.Errorf("toolgate: rule %q sets both tool and tag; use one", r.ID) + } + // Same reasoning as for tool_tags: a rule on a tag no tool carries + // governs nothing, and nobody would notice. + if r.Tag != "" && !tags[r.Tag] { + return fmt.Errorf("toolgate: rule %q uses tag %q, which no tool carries", r.ID, r.Tag) + } + if r.Kind == KindTool { + if r.Arg != "" { + return fmt.Errorf("toolgate: rule %q of kind tool takes no arg", r.ID) + } + if r.Tool == "" && r.Tag == "" { + return fmt.Errorf("toolgate: rule %q of kind tool needs a tool or a tag", r.ID) + } + } else if r.Arg == "" { + return fmt.Errorf("toolgate: rule %q needs an arg", r.ID) + } else if err := validPath(r.Arg); err != nil { + return fmt.Errorf("toolgate: rule %q: arg %v", r.ID, err) + } + if r.Each && !eachKinds[r.Kind] { + return fmt.Errorf("toolgate: rule %q of kind %s cannot apply to each element", r.ID, r.Kind) + } switch r.Kind { + case KindTool: + case KindFloor: + if r.Min == nil { + return fmt.Errorf("toolgate: rule %q needs min", r.ID) + } + case KindDenylist: + if len(r.Denied) == 0 { + return fmt.Errorf("toolgate: rule %q needs a denied list", r.ID) + } + case KindPattern: + if r.Pattern == "" { + return fmt.Errorf("toolgate: rule %q needs a pattern", r.ID) + } + if _, err := compiledPattern(r.Pattern); err != nil { + return fmt.Errorf("toolgate: rule %q has a bad pattern: %v", r.ID, err) + } + case KindDomain: + if len(r.Allowed) == 0 { + return fmt.Errorf("toolgate: rule %q needs an allowed list of domains", r.ID) + } + for _, d := range r.Allowed { + // A domain entry is a bare lower-case ASCII host name: "@corp.example", + // ".corp.example" or "https://corp.example" would never match. + if d == "" || d != strings.ToLower(d) || strings.ContainsAny(d, "@/:* ") || + strings.HasPrefix(d, ".") || strings.HasSuffix(d, ".") || !isASCII(d) { + return fmt.Errorf("toolgate: rule %q: %q is not a bare lower-case domain", r.ID, d) + } + } + case KindMaxItems: + if r.Max < 1 { + return fmt.Errorf("toolgate: rule %q needs max of at least 1", r.ID) + } + case KindCompare: + if !compareOps[r.Op] { + return fmt.Errorf("toolgate: rule %q needs op lt, le, gt, ge, eq or ne", r.ID) + } + if r.MatchArg == "" { + return fmt.Errorf("toolgate: rule %q needs match_arg", r.ID) + } + if strings.Contains(r.Arg+r.MatchArg, "[*]") { + return fmt.Errorf("toolgate: rule %q compares single values; [*] is not allowed", r.ID) + } + if err := validPath(r.MatchArg); err != nil { + return fmt.Errorf("toolgate: rule %q: match_arg %v", r.ID, err) + } case KindCeiling: case KindAllowlist, KindSuffix, KindPrefix: if len(r.Allowed) == 0 { @@ -209,73 +362,149 @@ func (r Rule) evaluate(call Call) (fired bool, effect, reason string) { return false, "", "" } effect = effectOrDeny(r) - v, ok := call.Args[r.Arg] - if !ok { - // The rule constrains an argument the call did not supply, so the - // constraint cannot be checked. Fail closed: an absent argument must - // not be a way around the rule (a tool whose upstream accepts a - // synonym, an alternate casing or its own default would otherwise - // sail straight past). evaluateLookup does the same. - return true, effect, unverifiable(r.Arg + " is required by this rule and was not supplied") + if r.Kind == KindTool { + return true, effect, "" } + if r.Kind == KindCompare { + return r.evaluateCompare(call, effect) + } + vals, multi, why := resolveArg(call.Args, r.Arg) + if why != "" { + // The rule constrains an argument the call did not supply (or a path + // that does not exist in it), so the constraint cannot be checked. + // Fail closed: an absent argument must not be a way around the rule + // (a tool whose upstream accepts a synonym, an alternate casing or its + // own default would otherwise sail straight past). evaluateLookup + // does the same. + return true, effect, unverifiable(why) + } + if r.Kind == KindMaxItems { + n, ok := countItems(vals, multi) + if !ok { + return true, effect, unverifiable(r.Arg + " is null, so its items cannot be counted") + } + return float64(n) > r.Max, effect, "" + } + if r.Each && !multi { + // A rule marked each checks every element of a list argument, and a + // lone value as a list of one. Without each, a list still reaches + // checkValue whole and fails closed there, as before. + if list, ok := vals[0].([]any); ok { + vals = list + } + } + for _, v := range vals { + if fired, reason := r.checkValue(v); fired { + return true, effect, reason + } + } + return false, "", "" +} + +// checkValue applies the rule's value check to one value. It is the single +// place each kind compares; evaluate decides which values reach it (the +// argument, an element of it, or a value found along a nested path). +func (r Rule) checkValue(v any) (fired bool, reason string) { switch r.Kind { case KindCeiling: f, ok := toFloat(v) if !ok { // Not a number, so it cannot be compared against the ceiling. // Fail closed: a quoted "999999" must not read as under the limit. - return true, effect, unverifiable(r.Arg + " is not a number, so it cannot be compared against the limit") + return true, unverifiable(r.Arg + " is not a number, so it cannot be compared against the limit") } - return f > r.Max, effect, "" + return f > r.Max, "" case KindAllowlist: s, ok := atomicValue(v) if !ok { - return true, effect, unverifiable(r.Arg + " is not a single plain value") + return true, unverifiable(r.Arg + " is not a single plain value") } for _, a := range r.Allowed { if s == a { - return false, "", "" + return false, "" } } - return true, effect, "" + return true, "" case KindSuffix: s, ok := atomicValue(v) if !ok { - return true, effect, unverifiable(r.Arg + " holds more than one value, or padding, so a suffix match is meaningless") + return true, unverifiable(r.Arg + " holds more than one value, or padding, so a suffix match is meaningless") } for _, a := range r.Allowed { if strings.HasSuffix(s, a) { - return false, "", "" + return false, "" } } - return true, effect, "" + return true, "" case KindPrefix: s, ok := atomicValue(v) if !ok { - return true, effect, unverifiable(r.Arg + " holds more than one value, or padding, so a prefix match is meaningless") + return true, unverifiable(r.Arg + " holds more than one value, or padding, so a prefix match is meaningless") } // An allowed prefix must not be escapable by walking back up out of // it: "corp-internal://../../../s3://attacker/" starts with the // allowed prefix and lands somewhere else entirely. if strings.Contains(s, "..") { - return true, effect, unverifiable(r.Arg + " walks back out of the allowed prefix") + return true, unverifiable(r.Arg + " walks back out of the allowed prefix") } for _, a := range r.Allowed { if strings.HasPrefix(s, a) { - return false, "", "" + return false, "" } } - return true, effect, "" + return true, "" + case KindFloor: + f, ok := toFloat(v) + if !ok { + return true, unverifiable(r.Arg + " is not a number, so it cannot be compared against the floor") + } + return f < *r.Min, "" + case KindDenylist: + s, ok := atomicValue(v) + if !ok { + return true, unverifiable(r.Arg + " is not a single plain value") + } + for _, d := range r.Denied { + if strings.EqualFold(s, d) { + return true, "" + } + } + return false, "" + case KindPattern: + s, ok := atomicValue(v) + if !ok { + return true, unverifiable(r.Arg + " is not a single plain value") + } + re, err := compiledPattern(r.Pattern) + if err != nil { + return true, unverifiable("the rule's pattern does not compile") + } + return !re.MatchString(s), "" + case KindDomain: + s, ok := atomicValue(v) + if !ok { + return true, unverifiable(r.Arg + " is not a single plain value") + } + host, why := hostOf(s) + if why != "" { + return true, unverifiable(r.Arg + " " + why) + } + for _, d := range r.Allowed { + if host == d || strings.HasSuffix(host, "."+d) { + return false, "" + } + } + return true, "" case KindSensitiveField: s, ok := atomicValue(v) if !ok { // The argument naming the field is not a lone scalar, so which // field is being changed cannot be established. Fail closed. - return true, effect, unverifiable(r.Arg + " is not a single plain value") + return true, unverifiable(r.Arg + " is not a single plain value") } - return normalizeField(s) == normalizeField(r.Field), effect, "" + return normalizeField(s) == normalizeField(r.Field), "" } - return false, "", "" + return false, "" } // evaluateLookup applies a lookup rule using the policy's table. @@ -283,7 +512,7 @@ func (p Policy) evaluateLookup(r Rule, call Call) (fired bool, effect, reason st if r.Tool != "" && r.Tool != call.Tool { return false, "", "" } - v, ok := call.Args[r.Arg] + v, ok := singleArg(call.Args, r.Arg) if !ok { // The value this rule checks is absent, so it cannot be checked // against the table. Fail closed for the same reason evaluate does: @@ -292,7 +521,7 @@ func (p Policy) evaluateLookup(r Rule, call Call) (fired bool, effect, reason st // where the money is allowed to go. return true, effectOrDeny(r), unverifiable(r.Arg + " is required by this rule and was not supplied") } - key, ok := call.Args[r.MatchArg] + key, ok := singleArg(call.Args, r.MatchArg) if !ok { return true, effectOrDeny(r), unverifiable(r.MatchArg + " was not supplied, so " + r.Arg + " cannot be checked against the table") } @@ -334,3 +563,175 @@ func toFloat(v any) (float64, bool) { } return 0, false } + +// evaluateCompare applies a compare rule: the rule fires unless Arg Op +// MatchArg holds. +func (r Rule) evaluateCompare(call Call, effect string) (fired bool, eff, reason string) { + a, ok := singleArg(call.Args, r.Arg) + if !ok { + return true, effect, unverifiable(r.Arg + " is required by this rule and was not supplied") + } + b, ok := singleArg(call.Args, r.MatchArg) + if !ok { + return true, effect, unverifiable(r.MatchArg + " was not supplied, so " + r.Arg + " cannot be compared with it") + } + fa, okA := toFloat(a) + fb, okB := toFloat(b) + if okA && okB { + var holds bool + switch r.Op { + case "lt": + holds = fa < fb + case "le": + holds = fa <= fb + case "gt": + holds = fa > fb + case "ge": + holds = fa >= fb + case "eq": + holds = fa == fb + case "ne": + holds = fa != fb + } + return !holds, effect, "" + } + if r.Op != "eq" && r.Op != "ne" { + // Ordering needs numbers; "999" against 1000 must not be decided as text. + return true, effect, unverifiable(r.Arg + " and " + r.MatchArg + " are not both numbers, so they cannot be ordered") + } + sa, okA := atomicValue(a) + sb, okB := atomicValue(b) + if !okA || !okB { + return true, effect, unverifiable(r.Arg + " or " + r.MatchArg + " is not a single plain value") + } + return (sa == sb) != (r.Op == "eq"), effect, "" +} + +// resolveArg finds the values a rule checks. It returns one value for a plain +// argument or path, and every value found for a path through "[*]" (multi), +// or a reason when the argument or path is absent. +func resolveArg(args map[string]any, path string) (vals []any, multi bool, why string) { + if v, ok := args[path]; ok { + return []any{v}, false, "" + } + if !strings.ContainsAny(path, ".[") { + return nil, false, path + " is required by this rule and was not supplied" + } + cur := []any{map[string]any(args)} + for _, seg := range strings.Split(path, ".") { + name, wild := strings.CutSuffix(seg, "[*]") + var next []any + for _, c := range cur { + obj, ok := c.(map[string]any) + if !ok { + return nil, false, path + " does not lead to a value in this call" + } + v, ok := obj[name] + if !ok { + return nil, false, path + " is required by this rule and was not supplied" + } + if !wild { + next = append(next, v) + continue + } + list, ok := v.([]any) + if !ok { + return nil, false, path + " expects a list at " + name + } + next = append(next, list...) + multi = true + } + cur = next + } + return cur, multi, "" +} + +// singleArg resolves a path that must lead to exactly one value. +func singleArg(args map[string]any, path string) (any, bool) { + vals, multi, why := resolveArg(args, path) + if why != "" || multi || len(vals) != 1 { + return nil, false + } + return vals[0], true +} + +// countItems counts what a max_items rule limits: the values a [*] path +// found, the elements of a list, or one for a lone value. Null is uncountable. +func countItems(vals []any, multi bool) (int, bool) { + if multi { + return len(vals), true + } + switch v := vals[0].(type) { + case nil: + return 0, false + case []any: + return len(v), true + } + return 1, true +} + +// validPath checks an argument path: non-empty segments, [*] only at a +// segment's end. +func validPath(path string) error { + for _, seg := range strings.Split(path, ".") { + name, _ := strings.CutSuffix(seg, "[*]") + if name == "" || strings.ContainsAny(name, "[]*") { + return fmt.Errorf("%q is not a valid argument path", path) + } + } + return nil +} + +// hostOf extracts the host a domain rule checks from an email address, a URL +// or a bare host name, lower-cased. It refuses what it cannot read without +// guessing: several "@", a URL without a host, and non-ASCII (homoglyph) +// hosts. +func hostOf(s string) (host, why string) { + switch { + case strings.Contains(s, "://"): + u, err := url.Parse(s) + if err != nil || u.Hostname() == "" { + return "", "is not a URL with a host" + } + // url.Parse already takes the host after any userinfo, so + // "https://corp.example@evil.example" reads as evil.example. + host = u.Hostname() + case strings.Contains(s, "@"): + if strings.Count(s, "@") != 1 { + return "", "has more than one @, so its domain is ambiguous" + } + host = s[strings.Index(s, "@")+1:] + default: + host = s + } + host = strings.TrimSuffix(strings.ToLower(host), ".") + if host == "" || !isASCII(host) { + return "", "has no plain ASCII host" + } + return host, "" +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > 0x7e || s[i] < 0x21 { + return false + } + } + return true +} + +var patterns sync.Map // pattern source -> *regexp.Regexp + +// compiledPattern compiles a pattern once, anchored so it must match the +// whole value. +func compiledPattern(p string) (*regexp.Regexp, error) { + if re, ok := patterns.Load(p); ok { + return re.(*regexp.Regexp), nil + } + re, err := regexp.Compile(`^(?:` + p + `)$`) + if err != nil { + return nil, err + } + patterns.Store(p, re) + return re, nil +} diff --git a/src/internal/toolgate/tags_test.go b/src/internal/toolgate/tags_test.go new file mode 100644 index 0000000..a56c22d --- /dev/null +++ b/src/internal/toolgate/tags_test.go @@ -0,0 +1,95 @@ +package toolgate + +import ( + "strings" + "testing" +) + +func taggedPolicy() Policy { + return Policy{ + Provider: "p", + GrantedScopes: []string{"files", "pay"}, + ToolScopes: map[string]string{"read_file": "files", "delete_file": "files", "wipe_disk": "files", "refund": "pay", "payout": "pay"}, + ToolTags: map[string][]string{ + "delete_file": {"destructive"}, "wipe_disk": {"destructive"}, + "refund": {"money"}, "payout": {"money"}, + }, + Rules: []Rule{ + {ID: "T1-destructive", Tag: "destructive", Kind: KindTool, Effect: EffectEscalate, Message: "destructive actions need a person"}, + {ID: "T2-money-ceiling", Tag: "money", Kind: KindCeiling, Arg: "amount", Max: 100, Message: "over the per-call limit"}, + }, + } +} + +// One rule governs every tool carrying its tag, and no other tool. +func TestTagRulesGovernTaggedToolsOnly(t *testing.T) { + g, err := New(taggedPolicy()) + if err != nil { + t.Fatal(err) + } + cases := []struct { + tool string + args map[string]any + want string + rule string + }{ + {"read_file", map[string]any{"path": "/tmp/x"}, DecisionAllow, ScopeRuleID}, + {"delete_file", map[string]any{"path": "/tmp/x"}, DecisionEscalate, "T1-destructive"}, + {"wipe_disk", nil, DecisionEscalate, "T1-destructive"}, + {"refund", map[string]any{"amount": 50}, DecisionAllow, ScopeRuleID}, + {"payout", map[string]any{"amount": 500}, DecisionDeny, "T2-money-ceiling"}, + // A tagged tool must still carry the argument its tag's rule checks. + {"refund", map[string]any{}, DecisionDeny, "T2-money-ceiling"}, + } + for _, c := range cases { + d := g.Decide(Call{Tool: c.tool, Args: c.args}) + if d.Outcome != c.want || d.Rule != c.rule { + t.Errorf("%s %v: got %s/%s, want %s/%s", c.tool, c.args, d.Outcome, d.Rule, c.want, c.rule) + } + } +} + +// Tags and tool rules fail validation on the mistakes that would otherwise +// leave a tool silently ungoverned or a rule firing everywhere. +func TestTagPolicyValidation(t *testing.T) { + bad := []struct { + name string + edit func(*Policy) + error string + }{ + {"tag on unknown tool", func(p *Policy) { p.ToolTags["delet_file"] = []string{"destructive"} }, "not in tool_scopes"}, + {"rule on unused tag", func(p *Policy) { p.Rules[0].Tag = "destrutive" }, "no tool carries"}, + {"tool and tag", func(p *Policy) { p.Rules[0].Tool = "delete_file" }, "both tool and tag"}, + {"tool rule with arg", func(p *Policy) { p.Rules[0].Arg = "path" }, "takes no arg"}, + {"tool rule unscoped", func(p *Policy) { p.Rules[0].Tag = "" }, "needs a tool or a tag"}, + {"arg rule without arg", func(p *Policy) { p.Rules[1].Arg = "" }, "needs an arg"}, + } + for _, b := range bad { + p := taggedPolicy() + b.edit(&p) + err := p.Validate() + if err == nil || !strings.Contains(err.Error(), b.error) { + t.Errorf("%s: got %v, want an error containing %q", b.name, err, b.error) + } + } + if err := taggedPolicy().Validate(); err != nil { + t.Errorf("valid tagged policy rejected: %v", err) + } +} + +// A tool rule scoped to one tool works without tags at all. +func TestToolRuleByName(t *testing.T) { + p := taggedPolicy() + p.ToolTags = nil + p.Rules = []Rule{{ID: "T3", Tool: "wipe_disk", Kind: KindTool}} + g, err := New(p) + if err != nil { + t.Fatal(err) + } + if d := g.Decide(Call{Tool: "wipe_disk"}); d.Outcome != DecisionDeny || d.Rule != "T3" { + t.Errorf("wipe_disk: got %s/%s, want deny/T3", d.Outcome, d.Rule) + } + if d := g.Decide(Call{Tool: "delete_file"}); d.Outcome != DecisionAllow { + t.Errorf("delete_file: got %s, want allow", d.Outcome) + } +} diff --git a/src/internal/toolgate/values_test.go b/src/internal/toolgate/values_test.go new file mode 100644 index 0000000..73707a9 --- /dev/null +++ b/src/internal/toolgate/values_test.go @@ -0,0 +1,185 @@ +package toolgate + +import ( + "encoding/json" + "strings" + "testing" +) + +func f64(v float64) *float64 { return &v } + +// decideWith builds a one-tool gate around rules and decides one call. +func decideWith(t *testing.T, args string, rules ...Rule) Decision { + t.Helper() + p := Policy{Provider: "p", GrantedScopes: []string{"s"}, ToolScopes: map[string]string{"t": "s"}, Rules: rules} + g, err := New(p) + if err != nil { + t.Fatalf("policy rejected: %v", err) + } + var a map[string]any + dec := json.NewDecoder(strings.NewReader(args)) + dec.UseNumber() // as the HTTP service decodes + if err := dec.Decode(&a); err != nil { + t.Fatal(err) + } + return g.Decide(Call{Tool: "t", Args: a}) +} + +type valueCase struct { + name, args string + want string // allow, deny, or "unverifiable" (deny with a cannot-verify message) +} + +func runCases(t *testing.T, rule Rule, cases []valueCase) { + t.Helper() + rule.ID, rule.Tool = "R", "t" + for _, c := range cases { + d := decideWith(t, c.args, rule) + got := d.Outcome + if d.Outcome == DecisionDeny && strings.HasPrefix(d.Message, "cannot verify:") { + got = "unverifiable" + } + if got != c.want { + t.Errorf("%s %s: got %s (%s), want %s", rule.Kind, c.name, got, d.Message, c.want) + } + } +} + +func TestFloor(t *testing.T) { + runCases(t, Rule{Kind: KindFloor, Arg: "qty", Min: f64(1)}, []valueCase{ + {"at floor", `{"qty": 1}`, DecisionAllow}, + {"below", `{"qty": 0}`, DecisionDeny}, + {"negative", `{"qty": -5}`, DecisionDeny}, + {"quoted number", `{"qty": "7"}`, "unverifiable"}, + {"absent", `{}`, "unverifiable"}, + }) +} + +func TestDenylistIgnoresCase(t *testing.T) { + runCases(t, Rule{Kind: KindDenylist, Arg: "acct", Denied: []string{"NEW-ACCT-9911"}}, []valueCase{ + {"other", `{"acct": "ACME-ACCT-001"}`, DecisionAllow}, + {"denied", `{"acct": "NEW-ACCT-9911"}`, DecisionDeny}, + {"denied, other case", `{"acct": "new-acct-9911"}`, DecisionDeny}, + {"padded", `{"acct": " NEW-ACCT-9911"}`, "unverifiable"}, + {"two values", `{"acct": "A,NEW-ACCT-9911"}`, "unverifiable"}, + }) +} + +func TestPatternMatchesWholeValue(t *testing.T) { + runCases(t, Rule{Kind: KindPattern, Arg: "id", Pattern: `ACME-ACCT-\d{3}`}, []valueCase{ + {"matches", `{"id": "ACME-ACCT-001"}`, DecisionAllow}, + {"prefix only", `{"id": "ACME-ACCT-0012"}`, DecisionDeny}, + {"embedded", `{"id": "xACME-ACCT-001"}`, DecisionDeny}, + {"list", `{"id": ["ACME-ACCT-001"]}`, "unverifiable"}, + }) +} + +func TestDomain(t *testing.T) { + runCases(t, Rule{Kind: KindDomain, Arg: "to", Allowed: []string{"corp.example"}}, []valueCase{ + {"email", `{"to": "cfo@corp.example"}`, DecisionAllow}, + {"email, subdomain", `{"to": "cfo@eu.corp.example"}`, DecisionAllow}, + {"email, upper case", `{"to": "CFO@CORP.EXAMPLE"}`, DecisionAllow}, + {"url", `{"to": "https://files.corp.example/x"}`, DecisionAllow}, + {"bare host", `{"to": "corp.example"}`, DecisionAllow}, + {"lookalike suffix", `{"to": "cfo@evilcorp.example"}`, DecisionDeny}, + {"lookalike parent", `{"to": "cfo@corp.example.evil.example"}`, DecisionDeny}, + {"url userinfo trick", `{"to": "https://corp.example@evil.example/x"}`, DecisionDeny}, + {"two @", `{"to": "a@corp.example@evil.example"}`, "unverifiable"}, + {"homoglyph", `{"to": "cfo@corp.exаmple"}`, "unverifiable"}, + {"url without host", `{"to": "file:///etc/passwd"}`, "unverifiable"}, + }) +} + +func TestMaxItems(t *testing.T) { + runCases(t, Rule{Kind: KindMaxItems, Arg: "to", Max: 2}, []valueCase{ + {"two", `{"to": ["a", "b"]}`, DecisionAllow}, + {"three", `{"to": ["a", "b", "c"]}`, DecisionDeny}, + {"lone value", `{"to": "a"}`, DecisionAllow}, + {"null", `{"to": null}`, "unverifiable"}, + }) + runCases(t, Rule{Kind: KindMaxItems, Arg: "items[*].id", Max: 1}, []valueCase{ + {"one via path", `{"items": [{"id": 1}]}`, DecisionAllow}, + {"two via path", `{"items": [{"id": 1}, {"id": 2}]}`, DecisionDeny}, + }) +} + +// each applies a single-value rule to every element; without it a list fails +// closed as before. +func TestEachElement(t *testing.T) { + rule := Rule{Kind: KindDomain, Arg: "to", Allowed: []string{"corp.example"}, Each: true} + runCases(t, rule, []valueCase{ + {"all inside", `{"to": ["a@corp.example", "b@eu.corp.example"]}`, DecisionAllow}, + {"one outside", `{"to": ["a@corp.example", "x@evil.example"]}`, DecisionDeny}, + {"lone value", `{"to": "a@corp.example"}`, DecisionAllow}, + {"empty list", `{"to": []}`, DecisionAllow}, + }) + rule.Each = false + runCases(t, rule, []valueCase{{"list without each", `{"to": ["a@corp.example"]}`, "unverifiable"}}) +} + +func TestNestedPaths(t *testing.T) { + runCases(t, Rule{Kind: KindCeiling, Arg: "payment.amount", Max: 100}, []valueCase{ + {"inside", `{"payment": {"amount": 50}}`, DecisionAllow}, + {"over", `{"payment": {"amount": 500}}`, DecisionDeny}, + {"missing field", `{"payment": {"currency": "USD"}}`, "unverifiable"}, + {"not an object", `{"payment": 500}`, "unverifiable"}, + }) + runCases(t, Rule{Kind: KindCeiling, Arg: "items[*].amount", Max: 100}, []valueCase{ + {"every item inside", `{"items": [{"amount": 10}, {"amount": 90}]}`, DecisionAllow}, + {"one item over", `{"items": [{"amount": 10}, {"amount": 900}]}`, DecisionDeny}, + {"one item without it", `{"items": [{"amount": 10}, {"note": "x"}]}`, "unverifiable"}, + {"not a list", `{"items": {"amount": 10}}`, "unverifiable"}, + }) + // An argument literally named with a dot is found by its exact name first. + runCases(t, Rule{Kind: KindCeiling, Arg: "a.b", Max: 1}, []valueCase{ + {"exact name", `{"a.b": 5}`, DecisionDeny}, + }) +} + +func TestCompare(t *testing.T) { + runCases(t, Rule{Kind: KindCompare, Arg: "refund", Op: "le", MatchArg: "charge"}, []valueCase{ + {"less", `{"refund": 10, "charge": 20}`, DecisionAllow}, + {"equal", `{"refund": 20, "charge": 20}`, DecisionAllow}, + {"more", `{"refund": 30, "charge": 20}`, DecisionDeny}, + {"text", `{"refund": "30", "charge": 20}`, "unverifiable"}, + {"other absent", `{"refund": 30}`, "unverifiable"}, + }) + runCases(t, Rule{Kind: KindCompare, Arg: "from", Op: "ne", MatchArg: "to"}, []valueCase{ + {"different", `{"from": "A-1", "to": "B-2"}`, DecisionAllow}, + {"same", `{"from": "A-1", "to": "A-1"}`, DecisionDeny}, + }) + runCases(t, Rule{Kind: KindCompare, Arg: "order.total", Op: "eq", MatchArg: "payment.amount"}, []valueCase{ + {"nested equal", `{"order": {"total": 5}, "payment": {"amount": 5}}`, DecisionAllow}, + {"nested differ", `{"order": {"total": 5}, "payment": {"amount": 6}}`, DecisionDeny}, + }) +} + +func TestValueKindValidation(t *testing.T) { + bad := []struct { + rule Rule + error string + }{ + {Rule{Kind: KindFloor, Arg: "x"}, "needs min"}, + {Rule{Kind: KindDenylist, Arg: "x"}, "needs a denied list"}, + {Rule{Kind: KindPattern, Arg: "x"}, "needs a pattern"}, + {Rule{Kind: KindPattern, Arg: "x", Pattern: "("}, "bad pattern"}, + {Rule{Kind: KindDomain, Arg: "x"}, "allowed list of domains"}, + {Rule{Kind: KindDomain, Arg: "x", Allowed: []string{"@corp.example"}}, "not a bare lower-case domain"}, + {Rule{Kind: KindDomain, Arg: "x", Allowed: []string{"Corp.example"}}, "not a bare lower-case domain"}, + {Rule{Kind: KindMaxItems, Arg: "x"}, "max of at least 1"}, + {Rule{Kind: KindCompare, Arg: "x", MatchArg: "y", Op: "lte"}, "needs op"}, + {Rule{Kind: KindCompare, Arg: "x", Op: "lt"}, "needs match_arg"}, + {Rule{Kind: KindCompare, Arg: "x[*].a", Op: "lt", MatchArg: "y"}, "[*] is not allowed"}, + {Rule{Kind: KindMaxItems, Arg: "x", Max: 2, Each: true}, "cannot apply to each"}, + {Rule{Kind: KindCeiling, Arg: "a..b", Max: 1}, "not a valid argument path"}, + {Rule{Kind: KindCeiling, Arg: "a[*]b", Max: 1}, "not a valid argument path"}, + } + for _, b := range bad { + b.rule.ID, b.rule.Tool = "R", "t" + p := Policy{Provider: "p", GrantedScopes: []string{"s"}, ToolScopes: map[string]string{"t": "s"}, Rules: []Rule{b.rule}} + err := p.Validate() + if err == nil || !strings.Contains(err.Error(), b.error) { + t.Errorf("%s %+v: got %v, want an error containing %q", b.rule.Kind, b.rule, err, b.error) + } + } +}