Skip to content
Open
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
26 changes: 22 additions & 4 deletions docs/concepts/policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,33 @@ Follow the principle of least privilege — all access denied unless explicitly
{
"tools": {
"allow": ["Read", "Edit", "Bash", "Glob", "Grep"],
"deny": ["Task"],
"deny": ["Task", "Agent"],
"requireApproval": ["Bash:rm -rf *", "Bash:git push --force"]
}
}
```

- `allow`: Tools the agent can use freely
- `deny`: Tools that are always blocked
- `requireApproval`: Patterns that require human confirmation
- `allow`: tools the agent can use freely. A non-empty `allow` list is itself a restriction — any tool not listed is denied.
- `deny`: tools that are always blocked.
- `requireApproval`: patterns that require human confirmation.

**Precedence:** `deny` > `requireApproval` > `allow`. A tool in both `allow` and `deny` is denied.

**Matching:** entries are glob-matched. A wildcard-free literal (e.g. `Task`) matches only that exact tool name — so `deny: ["Task"]` does **not** cover `Agent`. Use `Tool:pattern` to scope by command/argument (e.g. `Bash:rm -rf *`).

### Subagent trust boundary (issue #100)

`Task` and `Agent` are **subagent-spawning** tools, and they are two distinct names — deny **both**. This boundary matters because a spawned subagent runs Claude Code's **native** `Bash`/`Write`/`Edit`, which route through Claude Code's harness, **not** through aflock:

- **MCP mode** (`aflock serve`): a native subagent tool call never reaches aflock at all, so aflock cannot see or block it. `tools.deny` is moot for a native spawn here — deny `Task`/`Agent` in `.claude/settings.local.json` (`permissions.deny`) so Claude Code itself refuses the spawn.
- **Hook mode** (`aflock hook`): aflock's `PreToolUse` can **deny the spawn** outright, or — if the policy declares [sublayouts](sublayouts.md) — require the spawn to match a declared, attenuated sublayout, and it accounts for the child after the fact at `SubagentStop`. It does **not** intercept the subagent's individual native tool calls in real time: Claude Code does not fire parent hooks inside subagents ([claude-code#27661](https://github.com/anthropics/claude-code/issues/27661), [#34692](https://github.com/anthropics/claude-code/issues/34692)).

**Safe patterns:**

- **Forbid delegation (airtight):** deny `Task` and `Agent` in the policy and, for MCP mode, in `.claude/settings.local.json`. No spawn means no bypass.
- **Constrained delegation:** allow the spawn but declare `sublayouts` (hook mode) so the child is attenuated and bound to a named slot. The child's native calls are still not enforced in real time — only delegate to subagents you trust, and audit them via the child's own attestations.

aflock surfaces this trap two ways: it logs a startup **WARNING** (`AFLOCK_STRICT=1` refuses to start) when a policy permits `Task`/`Agent` without declaring sublayouts, and it tags `Task`/`Agent` attestations with `trustBoundaryCrossing` so `aflock verify` flags a session that delegated work out of the enforcement plane. The tag's *absence* does not prove no delegation occurred.

## File Access

Expand Down
6 changes: 4 additions & 2 deletions docs/concepts/sublayouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ sidebar_position: 4

# Sublayouts

:::caution Active Development
The sublayout security model is partially implemented. **Numeric limit attenuation** (invariant 1) and **metric accumulation** (invariant 2) work. **Attestation namespacing** (invariant 3), **recursive verification** (invariant 4), the `inherit` field, and constraint enforcement during sub-agent execution are not yet implemented. See [#26](https://github.com/aflock-ai/aflock/issues/26). **We're looking for contributors in this area.**
:::caution Implementation status
**Limit attenuation** (invariant 1), **metric accumulation** (invariant 2), **attestation namespacing** (invariant 3), and **recursive verification** (invariant 4) are implemented; the `inherit` field is applied at verification time. See [#26](https://github.com/aflock-ai/aflock/issues/26).

What sublayouts do **not** provide is real-time enforcement of a spawned subagent's own *native* tool calls. Those route through Claude Code's harness, not aflock, so aflock can gate the **spawn** (deny it, or require it to match a declared sublayout — hook mode only) and account for the child after the fact, but cannot block the child's individual `Bash`/`Write`/`Edit` calls. See the [subagent trust boundary](policies.md#subagent-trust-boundary-issue-100) (issue #100).
:::

Inspired by [in-toto sublayouts](https://github.com/in-toto/specification), aflock supports **hierarchical sub-agent delegation** with mandatory constraint attenuation.
Expand Down
12 changes: 12 additions & 0 deletions docs/reference/policy-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ Complete reference for the `.aflock` policy file format.
| `name` | string | Yes | Human-readable policy name |
| `expires` | string | No | ISO-8601 expiration datetime |

### Tools

| Field | Type | Description |
|-------|------|-------------|
| `allow` | string[] | Permitted tools/patterns. If non-empty, any tool not matched is denied. |
| `deny` | string[] | Always-blocked tools/patterns. |
| `requireApproval` | string[] | Patterns that prompt for human confirmation. |

- **Precedence:** `deny` > `requireApproval` > `allow`. A tool in both `allow` and `deny` is denied.
- **Matching:** glob-based. A wildcard-free literal (e.g. `Task`) matches that exact tool name only — `deny: ["Task"]` does **not** cover `Agent`. Use `Tool:pattern` to scope by command/argument (e.g. `Bash:rm -rf *`).
- **Subagent spawn tools:** `Task` and `Agent` spawn a subagent whose native tools bypass aflock — deny **both** (issue #100). See [Policies › Subagent trust boundary](../concepts/policies.md#subagent-trust-boundary-issue-100).

### Limits

| Field | Type | Description |
Expand Down
2 changes: 1 addition & 1 deletion examples/.aflock
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

"tools": {
"allow": ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "mcp__*"],
"deny": ["Task"]
"deny": ["Task", "Agent"]
},

"dataFlow": {
Expand Down
2 changes: 1 addition & 1 deletion examples/attestation-demo/.aflock
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "attestation-demo",
"tools": {
"allow": ["Read", "Bash", "Edit", "Glob", "Grep"],
"deny": ["Task"]
"deny": ["Task", "Agent"]
},
"files": {
"allow": ["src/**", "tests/**", "README.md"],
Expand Down
2 changes: 1 addition & 1 deletion examples/data-flow-policy.aflock
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

"tools": {
"allow": ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "mcp__*"],
"deny": ["Task"]
"deny": ["Task", "Agent"]
},

"dataFlow": {
Expand Down
13 changes: 13 additions & 0 deletions internal/attestation/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,14 @@ type ActionPredicate struct {
Metrics *MetricsPredicate `json:"metrics,omitempty"`
JWTBinding *JWTBinding `json:"jwtBinding,omitempty"`
SublayoutBinding *SublayoutBinding `json:"sublayoutBinding,omitempty"`
// TrustBoundaryCrossing marks an attestation for a subagent-spawning tool
// (Task/Agent). The spawned subagent runs native tools that route through
// Claude Code, not aflock, so aflock cannot attest the child's own actions
// (issue #100). Auditors should treat a session containing this tag as
// having delegated work out of aflock's enforcement plane — but its ABSENCE
// is NOT proof that no delegation occurred. omitempty so non-spawn tools and
// legacy attestations carry no field and verifiers treat it as false.
TrustBoundaryCrossing bool `json:"trustBoundaryCrossing,omitempty"`
}

// SublayoutBinding records the parent-declared sublayout this child session
Expand Down Expand Up @@ -399,6 +407,11 @@ func (s *Signer) CreateActionAttestation(
SublayoutBinding: sublayoutBinding,
}

// Tag subagent spawns (Task/Agent) as a trust-boundary crossing (#100): the
// child's native tools execute outside aflock's enforcement plane, so this
// attestation marks where the audited scope is delegated away.
predicate.TrustBoundaryCrossing = aflock.IsSubagentSpawn(record.ToolName)

if s.identity != nil {
predicate.AgentID = s.identity.SPIFFEID.String()
}
Expand Down
83 changes: 83 additions & 0 deletions internal/attestation/subagent_tag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package attestation

import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"

"github.com/aflock-ai/aflock/pkg/aflock"
)

func decodeActionPredicate(t *testing.T, env *Envelope) ActionPredicate {
t.Helper()
payloadBytes, err := base64.StdEncoding.DecodeString(env.Payload)
if err != nil {
t.Fatalf("decode payload: %v", err)
}
var stmt Statement
if err := json.Unmarshal(payloadBytes, &stmt); err != nil {
t.Fatalf("unmarshal statement: %v", err)
}
predBytes, err := json.Marshal(stmt.Predicate)
if err != nil {
t.Fatalf("marshal predicate: %v", err)
}
var pred ActionPredicate
if err := json.Unmarshal(predBytes, &pred); err != nil {
t.Fatalf("unmarshal predicate: %v", err)
}
return pred
}

// Task/Agent attestations are tagged as trust-boundary crossings (#100); other
// tools are not.
func TestCreateActionAttestation_TrustBoundaryCrossing(t *testing.T) {
signer, _, _ := newSignerWithIdentity(t)
ctx := context.Background()
now := time.Now()

tests := []struct {
toolName string
want bool
}{
{"Task", true},
{"Agent", true},
{"Bash", false},
{"Read", false},
}
for _, tt := range tests {
t.Run(tt.toolName, func(t *testing.T) {
rec := aflock.ActionRecord{
Timestamp: now,
ToolName: tt.toolName,
ToolUseID: "tu_" + tt.toolName,
Decision: "allow",
}
env, err := signer.CreateActionAttestation(ctx, rec, "session-tb", nil, nil, nil)
if err != nil {
t.Fatalf("CreateActionAttestation: %v", err)
}
if got := decodeActionPredicate(t, env).TrustBoundaryCrossing; got != tt.want {
t.Errorf("TrustBoundaryCrossing for %q = %v, want %v", tt.toolName, got, tt.want)
}
})
}
}

// The field is omitted from JSON for non-spawn tools (omitempty), so legacy and
// non-spawn attestations carry no field and verifiers treat it as false.
func TestActionPredicate_TrustBoundaryOmitempty(t *testing.T) {
signer, _, _ := newSignerWithIdentity(t)
rec := aflock.ActionRecord{Timestamp: time.Now(), ToolName: "Bash", ToolUseID: "tu_x", Decision: "allow"}
env, err := signer.CreateActionAttestation(context.Background(), rec, "s", nil, nil, nil)
if err != nil {
t.Fatalf("CreateActionAttestation: %v", err)
}
payloadBytes, _ := base64.StdEncoding.DecodeString(env.Payload)
if strings.Contains(string(payloadBytes), "trustBoundaryCrossing") {
t.Errorf("trustBoundaryCrossing should be omitted for a non-spawn tool, but the key is present")
}
}
13 changes: 12 additions & 1 deletion internal/hooks/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ func (h *Handler) handleSessionStart(input *aflock.HookInput) error {
return nil
}

// Subagent-bypass guardrail (#100): a policy that permits Task/Agent without
// declaring sublayouts lets the agent spawn an unconstrained subagent whose
// native tools bypass aflock. Warn at session start; AFLOCK_STRICT=1 refuses.
if warn, msg := policy.CheckSubagentMisconfig(pol); warn {
if os.Getenv("AFLOCK_STRICT") == "1" {
output.ExitWithError(fmt.Sprintf("[aflock] %s (AFLOCK_STRICT=1)", msg))
return nil
}
fmt.Fprintf(os.Stderr, "[aflock] WARNING (#100): %s\n", msg)
}

// Discover agent identity. If the policy has identity constraints
// (AllowedModels), a discovery failure must block the session — otherwise
// the constraint is silently bypassed (issue #60 / H7).
Expand Down Expand Up @@ -1532,7 +1543,7 @@ func attestationMatchesName(path, name string) bool {

// isSubagentSpawn returns true if the tool triggers a subagent spawn.
func isSubagentSpawn(toolName string) bool {
return toolName == "Agent" || toolName == "Task"
return aflock.IsSubagentSpawn(toolName)
}

// matchSublayoutForSpawn finds the declared sublayout this spawn should bind
Expand Down
33 changes: 33 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ func (s *Server) Serve(policyPath string) error {
return err
}

// Subagent-bypass guardrail (#100).
if err := s.warnSubagentMisconfig(); err != nil {
return err
}

// Identity discovery + policy-digest binding per paper §3.1.
s.initAgentIdentity()

Expand Down Expand Up @@ -349,6 +354,11 @@ func (s *Server) ServeHTTP(policyPath string, port int) error {
return err
}

// Subagent-bypass guardrail (#100).
if err := s.warnSubagentMisconfig(); err != nil {
return err
}

// Identity discovery + policy-digest binding per paper §3.1. Mirrors
// Serve() — previously missing on the HTTP transport, which caused JWTs
// issued via SSE to have empty identity_hash and attestations to miss
Expand Down Expand Up @@ -504,6 +514,11 @@ func (s *Server) ServeUnix(policyPath, socketPath string) error {
return err
}

// Subagent-bypass guardrail (#100).
if err := s.warnSubagentMisconfig(); err != nil {
return err
}

// Refuse to start if the socket path already exists. Lstat (not Stat)
// so a dangling symlink also blocks us — an attacker pre-creating either
// a file or a symlink at the path could otherwise win the bind race.
Expand Down Expand Up @@ -631,6 +646,24 @@ func (s *Server) errPolicyExpired() error {
return fmt.Errorf("policy %q expired at %s", s.policy.Name, s.policy.Expires.Format(time.RFC3339))
}

// warnSubagentMisconfig logs a startup WARNING (or, with AFLOCK_STRICT=1,
// returns an error to refuse startup) when the loaded policy permits spawning
// an unconstrained subagent — the issue #100 enforcement bypass. In MCP mode a
// subagent's native Bash/Write/Edit never route through aflock at all, so this
// startup notice is the only point aflock can flag the misconfiguration to the
// operator (deny Task/Agent in .claude/settings.local.json too).
func (s *Server) warnSubagentMisconfig() error {
warn, msg := policy.CheckSubagentMisconfig(s.policy)
if !warn {
return nil
}
if os.Getenv("AFLOCK_STRICT") == "1" {
return fmt.Errorf("[aflock] %s (AFLOCK_STRICT=1)", msg)
}
fmt.Fprintf(os.Stderr, "[aflock] WARNING (#100): %s\n", msg)
return nil
}

// computePolicyDigest returns the SHA-256 digest of the loaded policy.
//
// Prefers s.policy.RawDigest (set by policy.Load from the on-disk bytes) so
Expand Down
52 changes: 52 additions & 0 deletions internal/policy/subagent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package policy

import (
"fmt"
"strings"

"github.com/aflock-ai/aflock/pkg/aflock"
)

// CheckSubagentMisconfig reports whether the loaded policy permits spawning an
// unconstrained subagent, which opens the issue #100 enforcement bypass.
//
// Claude Code's Task/Agent tools spawn a subagent whose NATIVE Bash/Write/Edit
// calls route through Claude Code's harness, not through aflock — so aflock
// cannot see or block them. A policy is flagged when BOTH:
//
// - it permits Task or Agent (the spawn is not denied), AND
// - it declares no sublayouts. Sublayouts gate Task/Agent spawns at
// PreToolUse in hook mode (R3-291), so a policy that declares them is opting
// into constrained delegation on purpose and is not flagged.
//
// Permission is evaluated with the SAME matching the enforcement path uses
// (EvaluatePreToolUse), so a deny of "*" or "{Task,Agent}" correctly suppresses
// the warning and an allow expressed via a glob is still detected. Returns
// (false, "") when the policy is safe.
func CheckSubagentMisconfig(pol *aflock.Policy) (warn bool, msg string) {
if pol == nil {
return false, ""
}
if len(pol.Sublayouts) > 0 {
return false, ""
}

eval := NewEvaluator(pol, "")
var permitted []string
for _, tool := range aflock.SubagentSpawnTools {
if decision, _ := eval.EvaluatePreToolUse(tool, nil); decision != aflock.DecisionDeny {
permitted = append(permitted, tool)
}
}
if len(permitted) == 0 {
return false, ""
}

return true, fmt.Sprintf(
"policy %q permits subagent spawning (%s) without declaring sublayouts. "+
"A spawned subagent's native tools route through Claude Code, not aflock, "+
"so they are NOT enforced (issue #100). Deny %s in tools.deny (and in "+
".claude/settings.local.json when running in MCP mode), or declare "+
"sublayouts to constrain delegation.",
pol.Name, strings.Join(permitted, ", "), strings.Join(permitted, " and "))
}
Loading
Loading