Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions internal/auth/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,83 @@ func (ti *TokenIssuer) IssueToken(
return token.SignedString(ti.signingKey)
}

// MintChildToken issues a JWT for a child session bound to a parent
// sublayout. The token's scope is the intersection of the parent
// policy with the sublayout: parent tools (unchanged — sublayouts
// don't broaden tool access), sublayout limits where declared
// (falling back to the parent's limit otherwise), and a fresh
// PolicyDigest computed over the attenuated policy so verification
// can't replay this token against the parent or any other sublayout.
//
// Used by `aflock_delegate` (issue #117) to mirror, in MCP mode,
// the spawn-time binding that hooks-mode performs in
// internal/hooks/handler.go:matchSublayoutForSpawn.
func (ti *TokenIssuer) MintChildToken(
parentPolicy *aflock.Policy,
sub *aflock.Sublayout,
childSessionID string,
agentID string,
identityHash string,
ttl time.Duration,
) (string, error) {
if parentPolicy == nil {
return "", fmt.Errorf("mint child token: nil parent policy")
}
if sub == nil {
return "", fmt.Errorf("mint child token: nil sublayout")
}
child := attenuateChildPolicy(parentPolicy, sub)
return ti.IssueToken(childSessionID, agentID, identityHash, child, ttl)
Comment on lines +145 to +146
}

// attenuateChildPolicy builds an attenuated policy for a sublayout
// child. Tools and grants are carried from the parent (the sublayout
// is a delegation, not a broadening); limits are merged field-by-field
// with the sublayout taking precedence where present.
func attenuateChildPolicy(parent *aflock.Policy, sub *aflock.Sublayout) *aflock.Policy {
child := &aflock.Policy{
Name: sub.Name,
Version: parent.Version,
Tools: parent.Tools,
}
child.Limits = mergeLimits(parent.Limits, sub.Limits)
return child
}

// mergeLimits returns a new LimitsPolicy where each field is the
// sublayout's value if set, otherwise the parent's value. Caller is
// responsible for attenuation validation (sub <= parent) before
// calling this — mergeLimits trusts its inputs.
func mergeLimits(parent, sub *aflock.LimitsPolicy) *aflock.LimitsPolicy {
if parent == nil && sub == nil {
return nil
}
out := &aflock.LimitsPolicy{}
pick := func(p, s *aflock.Limit) *aflock.Limit {
if s != nil {
return s
}
return p
}
if parent != nil {
out.MaxSpendUSD = parent.MaxSpendUSD
out.MaxTokensIn = parent.MaxTokensIn
out.MaxTokensOut = parent.MaxTokensOut
out.MaxTurns = parent.MaxTurns
out.MaxWallTimeSeconds = parent.MaxWallTimeSeconds
out.MaxToolCalls = parent.MaxToolCalls
}
if sub != nil {
out.MaxSpendUSD = pick(out.MaxSpendUSD, sub.MaxSpendUSD)
out.MaxTokensIn = pick(out.MaxTokensIn, sub.MaxTokensIn)
out.MaxTokensOut = pick(out.MaxTokensOut, sub.MaxTokensOut)
out.MaxTurns = pick(out.MaxTurns, sub.MaxTurns)
out.MaxWallTimeSeconds = pick(out.MaxWallTimeSeconds, sub.MaxWallTimeSeconds)
out.MaxToolCalls = pick(out.MaxToolCalls, sub.MaxToolCalls)
}
return out
}

// ValidateToken verifies signature, expiry, issuer, and parses claims.
func (ti *TokenIssuer) ValidateToken(tokenString string) (*AflockClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AflockClaims{},
Expand Down
45 changes: 45 additions & 0 deletions internal/auth/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,48 @@ func TestValidatorRejectsForeignToken(t *testing.T) {
_, err = validatorB.ValidateToken(tokenA)
assert.Error(t, err)
}

func TestMintChildToken_MergesLimits(t *testing.T) {
issuer, err := NewTokenIssuer()
require.NoError(t, err)

parent := &aflock.Policy{
Version: "1.0",
Name: "parent",
Tools: &aflock.ToolsPolicy{Allow: []string{"Read", "Bash"}},
Limits: &aflock.LimitsPolicy{
MaxSpendUSD: &aflock.Limit{Value: 10.0, Enforcement: "fail-fast"},
MaxTokensIn: &aflock.Limit{Value: 500_000, Enforcement: "fail-fast"},
},
}
sub := &aflock.Sublayout{
Name: "research-agent",
Limits: &aflock.LimitsPolicy{
MaxSpendUSD: &aflock.Limit{Value: 2.0, Enforcement: "fail-fast"},
// MaxTokensIn not declared — should inherit from parent.
},
}

tok, err := issuer.MintChildToken(parent, sub, "child-1", "spiffe://aflock.ai/agent/child", "hash-1", time.Hour)
require.NoError(t, err)

claims, err := issuer.ValidateTokenForSessionAndPolicy(tok, "child-1", "")
require.NoError(t, err)

require.NotNil(t, claims.Limits, "child JWT must carry limits")
require.NotNil(t, claims.Limits.MaxSpendUSD, "MaxSpendUSD must be present")
assert.Equal(t, 2.0, claims.Limits.MaxSpendUSD.Value, "child takes sublayout's MaxSpendUSD")
require.NotNil(t, claims.Limits.MaxTokensIn, "MaxTokensIn must inherit from parent")
assert.Equal(t, 500_000.0, claims.Limits.MaxTokensIn.Value, "child inherits parent's MaxTokensIn")
}

func TestMintChildToken_NilArgs(t *testing.T) {
issuer, err := NewTokenIssuer()
require.NoError(t, err)

_, err = issuer.MintChildToken(nil, &aflock.Sublayout{Name: "x"}, "child", "agent", "hash", time.Hour)
assert.Error(t, err, "nil parent policy must error")

_, err = issuer.MintChildToken(&aflock.Policy{}, nil, "child", "agent", "hash", time.Hour)
assert.Error(t, err, "nil sublayout must error")
}
28 changes: 1 addition & 27 deletions internal/hooks/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ func (h *Handler) handlePreToolUse(input *aflock.HookInput) error {
case hasDecl && matched != nil:
// Sublayout limits must already attenuate vs parent's limits;
// refusing at spawn time means a bad policy can't slip through.
if violations := attenuationViolations(sessionState.Policy.Limits, matched.Limits); len(violations) > 0 {
if violations := policy.AttenuationViolations(sessionState.Policy.Limits, matched.Limits); len(violations) > 0 {
return output.Write(output.PreToolUseDeny(fmt.Sprintf(
"[aflock] BLOCKED: sublayout %q violates parent attenuation: %s",
matched.Name, strings.Join(violations, "; "))))
Expand Down Expand Up @@ -1570,32 +1570,6 @@ func matchSublayoutForSpawn(toolName string, toolInput json.RawMessage, sublayou
return nil, true
}

// attenuationViolations checks that sublayout limits are <= parent limits.
// Mirrors verify.verifyAttenuation but lives here so spawn-time enforcement
// (issue #26 gap 4) doesn't pull in the verify package's dependencies.
// Empty result means the sublayout is validly attenuated.
func attenuationViolations(parent, sub *aflock.LimitsPolicy) []string {
if sub == nil || parent == nil {
return nil
}
var violations []string
check := func(name string, p, s *aflock.Limit) {
if p == nil || s == nil {
return
}
if s.Value > p.Value {
violations = append(violations, fmt.Sprintf("%s: sublayout %.2f > parent %.2f", name, s.Value, p.Value))
}
}
check("maxSpendUSD", parent.MaxSpendUSD, sub.MaxSpendUSD)
check("maxTokensIn", parent.MaxTokensIn, sub.MaxTokensIn)
check("maxTokensOut", parent.MaxTokensOut, sub.MaxTokensOut)
check("maxTurns", parent.MaxTurns, sub.MaxTurns)
check("maxWallTimeSeconds", parent.MaxWallTimeSeconds, sub.MaxWallTimeSeconds)
check("maxToolCalls", parent.MaxToolCalls, sub.MaxToolCalls)
return violations
}

// attenuateLimits computes effective limits for a child session.
// For each limit field: child effective = min(child policy limit, parent remaining).
// If a parent has exhausted its budget, the child gets 0.
Expand Down
Loading