Parameter-level constraints

Constraints narrow a passport below the granularity of an OAuth scope. Where a scope says "Slack: can post messages," a constraint says "Slack: can post messages, but only to channels C0123 or C0456." Constraints are evaluated on every proxy call and a failing constraint denies the request the same way an out-of-scope service does.

Constraints ride on a passport's services[].constraintsarray. A grant can carry baseline constraints that every passport inherits; passports issued from that grant can narrow further but never loosen.

Shape

A constraint is a three-tuple of path,op, and value:

json
{
  "path": "body.channel",
  "op": "in",
  "value": ["C0123", "C0456"]
}

Operators

  • eq - value at path equals the constraint value
  • not_eq - value at path does not equal
  • in - value at path is present in the constraint value (array)
  • not_in - value at path is absent from the constraint value (array)
  • matches - value at path matches the RE2 regular expression (max 256 chars)
  • starts_with - value at path starts with the constraint value (string prefix)

matches uses RE2 and caps the pattern at 256 characters. RE2 rejects backtracking constructs that would enable catastrophic backtracking.

Path syntax

Paths resolve against a normalized view of the outbound request. Case-insensitive header lookup, stable URL parsing, and dotted traversal into JSON bodies are handled for you.

  • method - HTTP verb, uppercase (GET, POST, …)
  • url.pathname - path component of the URL, trailing slash stripped
  • url.host - host in lowercase
  • url.origin - scheme + host + port
  • headers.<name> - header value; name is lowercased for the lookup
  • query.<key> - query string value
  • body.<dotted.path> - JSON body traversal; supports nested fields (body.message.channel)

Evaluation semantics

  • AND - every constraint in the array must pass; one failing constraint denies
  • Max 32 constraints per ServiceScope - enforced at issue and delegate time
  • Missing path → treated as undefined; most ops fail closed (except not_eq, not_in which pass)
  • String values capped at 1024 characters; array values at 256 entries of 1024 chars each

Delegation

Child constraints must be a superset of parent constraints - every parent rule rides through, and the child may add more. The delegate call validates this viaconstraintsAreSubset(merged, parent) and throwsSCOPE_ESCALATION if a parent rule was dropped.

Example: lock Slack posts to two channels

bash
curl -s -X POST https://api.getstack.run/v1/services/grant \
  -H "Authorization: Bearer $STACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agt_support_bot",
    "service_connection_id": "scon_slack_prod",
    "scopes": ["chat:write"],
    "constraints": [
      { "path": "url.pathname", "op": "eq", "value": "/api/chat.postMessage" },
      { "path": "body.channel", "op": "in", "value": ["C0123", "C0456"] }
    ]
  }'

Any passport issued against this grant can only post messages, and only to those two channels. An attempt to post to a third channel is denied at the proxy withFORBIDDEN and records acredential_outside_scope security event.

Intents vs raw constraints

Raw constraints are the primitive. Named intents are a higher-level shorthand that expand to constraints server-side - the name encodes the provider convention so you don't have to reinvent it each time. For a catalog of intents and how they expand, see/docs/concepts/intents.

stack | docs