Problem
Squadron plugins today take static credentials from the vault at boot and use them for every call. That's fine for solo/personal use, but breaks down for anyone running squadron as a service:
- Every call to a backend looks like one shared account — no attribution
- Static tokens can't be scoped per-mission or per-principal
- No audit trail of "who did what" through a plugin
- Enterprises have existing identity infra (Okta, Entra, SPIFFE, cloud IAM) and want plugins to use it
Design in one sentence
Squadron propagates a principal into every plugin call, and plugins that need a backend token ask squadron to mint one — using whatever identity source the operator has wired up.
Two kinds of principal
| How the mission was launched |
Principal |
Typical token flow |
| Scheduler, webhook, cron (service mode) |
Workload identity |
Client credentials / JWT-bearer |
| Human via CLI or UI (personal mode) |
User identity |
On-behalf-of exchange (RFC 8693) |
Most squadron deployments will run as services. Some users will run squadron purely as a personal automation tool. The design supports both equally — the difference is just which field on CallContext is populated.
How it works
1. Every plugin call carries a CallContext
type CallContext struct {
Principal Principal // oneof: UserJWT | WorkloadToken
MissionID string
TaskID string
RequestID string
Constraints map[string]string // mission-derived data-access constraints (see below)
}
Local plugins (shell, parsers, file ops) ignore it entirely.
2. Plugins that need a backend token ask the host
tok, err := host.ExchangeToken(ctx, "jira.acme.com", []string{"issues:write"})
Squadron picks the right grant based on what's in CallContext. The plugin author writes one line regardless of whether a human or a cron job is driving.
3. Plugins declare what they need in ToolInfo
RequiresIdentity: &IdentityRequirement{
Audience: "jira.acme.com",
Scopes: []string{"issues:write"},
Accepts: []PrincipalKind{User, Workload},
}
Squadron uses this to fail fast at config load, show the trust surface in squadron plugin info, and refuse to launch a mission missing a principal the tool needs.
How operators wire it up
Because squadron is open and runs everywhere, every integration point is pluggable. There's no hardcoded IdP, no hardcoded workload-identity source, no hardcoded grant type. Squadron ships reference implementations for the common ones; anything else slots in via the same interfaces.
identity_provider {
source = "github.com/floze/squadron-idp-oidc"
settings = { issuer = "https://acme.okta.com" }
}
workload_identity {
source = "github.com/floze/squadron-workload-spiffe"
settings = { socket = "unix:///run/spire/agent.sock" }
}
The plugin-author contract doesn't change regardless of which of these the operator picks.
Mission-level data-access constraints
Beyond who is calling (Principal) and what capabilities the plugin needs (OAuth scopes), we want to constrain what slice of data a mission run can touch. Mission inputs like merchant_id should bind every applicable tool call for the lifetime of the mission, with no way for the LLM to widen them.
Naming note. "Scope" already means OAuth scope in this design (IdentityRequirement.Scopes). To avoid conflation, the data-access dimension is called constraints throughout: CallContext.Constraints, HCL constraints { ... }, ConstraintBindings. OAuth scopes name capabilities; constraints name values that filter what those capabilities can reach.
CallContext.Constraints
The Constraints field on CallContext (above) is a map[string]string populated from mission inputs that the HCL marks as data-access constraints.
HCL: constraints block on a mission
mission "merchant_report" {
inputs = {
merchant_id = string("Merchant", true)
}
constraints = {
merchant_id = inputs.merchant_id
}
task "fetch" { objective = "Summarize merchant activity" }
}
Validation at config load: every key in constraints must resolve to a mission input.
Tools declare which constraint keys they consume
IdentityRequirement gains ConstraintBindings:
type IdentityRequirement struct {
Audience string
Scopes []string // OAuth capabilities (existing)
Accepts []PrincipalKind
ConstraintBindings []ConstraintBinding // NEW: mission-input filters
}
type ConstraintBinding struct {
Key string // CallContext.Constraints key, e.g. "merchant_id"
Param string // tool-payload path it binds to, e.g. "filters.merchant_id"
Required bool // mission must supply this constraint, or the tool is unusable
}
A search_transactions tool would declare:
RequiresIdentity: &IdentityRequirement{
Audience: "ledger.acme.com",
Scopes: []string{"transactions:read"}, // capability
Accepts: []PrincipalKind{User, Workload},
ConstraintBindings: []ConstraintBinding{ // data filter
{Key: "merchant_id", Param: "filters.merchant_id", Required: true},
},
}
What the host does at call time
- Strips bound params from the schema shown to the LLM. The model never sees
filters.merchant_id as a settable field — can't be prompt-injected to widen it.
- Auto-injects from
CallContext.Constraints before unmarshal into the plugin payload.
- Rejects unbound calls. If
Required = true and Constraints[Key] is empty, the tool call returns an error before reaching the plugin.
- Bakes constraint claims into minted tokens. When the plugin calls
host.ExchangeToken(ctx, audience, scopes), the host adds CallContext.Constraints into the minted token's claims (JWT custom claims, downscoped OAuth resource indicators, etc.) so the backend can enforce too.
Two enforcement layers, made explicit
| Layer |
Mechanism |
Defends against |
| Structural (always on) |
hidden schema + host-side injection + override rejection |
LLM hallucination, prompt injection |
| Cryptographic (opt-in via backend) |
constraint claims baked into token, backend enforces |
compromised plugin, code-level escape |
The structural layer alone is useful — it stops honest mistakes and most attacks against the LLM. Real defense-in-depth needs the backend to participate, same caveat that already applies to the principal-based design.
Config-load validation
Squadron refuses to launch a mission if:
- A key referenced by
mission.constraints is not in inputs
- A tool with
ConstraintBindings[i].Required = true is used by the mission and mission.constraints doesn't supply that key
Same fail-fast philosophy as the existing RequiresIdentity validation.
What this means for plugin authors
| Plugin type |
What you do |
| Local tool |
Nothing. |
| Backend with a static API key that works for your use case |
Nothing. Keep reading settings from vault. |
| Backend behind SSO or workload-scoped auth |
Declare RequiresIdentity, call host.ExchangeToken at call time. |
| Backend that filters by a mission-supplied dimension (tenant, merchant, region, ...) |
Add ConstraintBindings so the host injects the value and the LLM can't widen it. |
Explicitly not doing
- Not forcing any plugin through an IdP — opt-in per plugin, per tool
- Not replacing vault-based static credentials — still the right answer in many cases
- Not mandating SPIFFE, OPA, Okta, or any specific vendor — the interfaces are open
- Not changing agent/commander logic — this is purely below the tool-call boundary
Rollout
- Proto change: add
CallContext (including Constraints) to the plugin gRPC contract
- Host-side
ExchangeToken service exposed to plugins; bakes Constraints into minted token claims
ToolInfo.RequiresIdentity with ConstraintBindings + config-load validation; HCL constraints block on missions
- Reference implementations: one
IdentityProvider plugin (generic OIDC), one WorkloadIdentity plugin (static JWT to start, SPIFFE next), one end-to-end demo plugin that uses both a principal and a merchant_id constraint binding
1–3 land together. 4 is the usable demo.
Questions for early users
- Are you running squadron mostly as a service, mostly personal, or both?
- Which backends do you hit first that need real auth (Jira, GitHub, Snowflake, internal APIs)?
- Where would your principal come from — SSO provider, SPIFFE, cloud IAM, static JWT, something else?
- Do you have mission-level data-access constraints (tenant/merchant/region/customer) where the LLM should never be allowed to widen the filter?
- Is the "plugin declares what backend+scopes it needs" contract enough information for your security review process, or do you need more?
Problem
Squadron plugins today take static credentials from the vault at boot and use them for every call. That's fine for solo/personal use, but breaks down for anyone running squadron as a service:
Design in one sentence
Squadron propagates a principal into every plugin call, and plugins that need a backend token ask squadron to mint one — using whatever identity source the operator has wired up.
Two kinds of principal
Most squadron deployments will run as services. Some users will run squadron purely as a personal automation tool. The design supports both equally — the difference is just which field on
CallContextis populated.How it works
1. Every plugin call carries a
CallContextLocal plugins (shell, parsers, file ops) ignore it entirely.
2. Plugins that need a backend token ask the host
Squadron picks the right grant based on what's in
CallContext. The plugin author writes one line regardless of whether a human or a cron job is driving.3. Plugins declare what they need in
ToolInfoSquadron uses this to fail fast at config load, show the trust surface in
squadron plugin info, and refuse to launch a mission missing a principal the tool needs.How operators wire it up
Because squadron is open and runs everywhere, every integration point is pluggable. There's no hardcoded IdP, no hardcoded workload-identity source, no hardcoded grant type. Squadron ships reference implementations for the common ones; anything else slots in via the same interfaces.
The plugin-author contract doesn't change regardless of which of these the operator picks.
Mission-level data-access constraints
Beyond who is calling (Principal) and what capabilities the plugin needs (OAuth scopes), we want to constrain what slice of data a mission run can touch. Mission inputs like
merchant_idshould bind every applicable tool call for the lifetime of the mission, with no way for the LLM to widen them.CallContext.ConstraintsThe
Constraintsfield onCallContext(above) is amap[string]stringpopulated from mission inputs that the HCL marks as data-access constraints.HCL:
constraintsblock on a missionValidation at config load: every key in
constraintsmust resolve to a mission input.Tools declare which constraint keys they consume
IdentityRequirementgainsConstraintBindings:A
search_transactionstool would declare:What the host does at call time
filters.merchant_idas a settable field — can't be prompt-injected to widen it.CallContext.Constraintsbefore unmarshal into the plugin payload.Required = trueandConstraints[Key]is empty, the tool call returns an error before reaching the plugin.host.ExchangeToken(ctx, audience, scopes), the host addsCallContext.Constraintsinto the minted token's claims (JWT custom claims, downscoped OAuth resource indicators, etc.) so the backend can enforce too.Two enforcement layers, made explicit
The structural layer alone is useful — it stops honest mistakes and most attacks against the LLM. Real defense-in-depth needs the backend to participate, same caveat that already applies to the principal-based design.
Config-load validation
Squadron refuses to launch a mission if:
mission.constraintsis not ininputsConstraintBindings[i].Required = trueis used by the mission andmission.constraintsdoesn't supply that keySame fail-fast philosophy as the existing
RequiresIdentityvalidation.What this means for plugin authors
settingsfrom vault.RequiresIdentity, callhost.ExchangeTokenat call time.ConstraintBindingsso the host injects the value and the LLM can't widen it.Explicitly not doing
Rollout
CallContext(includingConstraints) to the plugin gRPC contractExchangeTokenservice exposed to plugins; bakesConstraintsinto minted token claimsToolInfo.RequiresIdentitywithConstraintBindings+ config-load validation; HCLconstraintsblock on missionsIdentityProviderplugin (generic OIDC), oneWorkloadIdentityplugin (static JWT to start, SPIFFE next), one end-to-end demo plugin that uses both a principal and amerchant_idconstraint binding1–3 land together. 4 is the usable demo.
Questions for early users