Skip to content

Commit 5e042f5

Browse files
committed
Merge branch 'develop'
2 parents 7e75f65 + 7aed833 commit 5e042f5

11 files changed

Lines changed: 96 additions & 15 deletions

File tree

.githooks/prepare-commit-msg

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# prepare-commit-msg — stamp Claude co-author trailer on Claude-driven commits.
5+
#
6+
# Why this exists:
7+
# commit.template (.gitmessage) only fires when git opens an editor.
8+
# `git commit -m "..."` and HEREDOC variants (used by Claude Code and
9+
# scripts) bypass it entirely. That is why commits after 2026-03-30
10+
# stopped carrying the "Co-authored-by: Claude" line even though
11+
# .gitmessage still declared the intent.
12+
#
13+
# This hook runs on every commit path, strips any existing Claude
14+
# co-author line (in any historical variant — "Claude", "Claude Opus 4.6",
15+
# "Claude Sonnet 4.6"), then appends the canonical trailer. Result:
16+
# exactly one Claude trailer in a consistent form across the log.
17+
#
18+
# Scope:
19+
# Only stamps when Claude is clearly the driver. Detection is via the
20+
# CLAUDECODE=1 environment variable, which Claude Code sets in the env
21+
# of every shell command it runs (including `git commit`). A commit you
22+
# run yourself in your own terminal has no CLAUDECODE set and is left
23+
# untouched — your solo commits stay honestly attributed to you alone.
24+
#
25+
# This is a stronger honesty guarantee than the old .gitmessage template,
26+
# which stamped every editor-driven commit regardless of who was typing.
27+
#
28+
# Skipped commit sources:
29+
# - merge : git is assembling a merge commit message; don't stamp.
30+
# - squash : git is assembling a squash message from other commits.
31+
# - commit : this is an --amend or cherry-pick reusing an existing
32+
# message. The trailer is already on the source commit
33+
# (if it should be). Leave it alone so amends don't mutate
34+
# history unexpectedly.
35+
#
36+
# Install (one-time per clone — already set repo-wide):
37+
# git config core.hooksPath .githooks
38+
39+
COMMIT_MSG_FILE="$1"
40+
COMMIT_SOURCE="${2:-}"
41+
42+
# Only stamp when Claude Code is driving this commit.
43+
if [[ "${CLAUDECODE:-}" != "1" ]]; then
44+
exit 0
45+
fi
46+
47+
case "$COMMIT_SOURCE" in
48+
merge|squash|commit) exit 0 ;;
49+
esac
50+
51+
TRAILER="Co-authored-by: Claude <noreply@anthropic.com>"
52+
53+
TMP=$(mktemp)
54+
trap 'rm -f "$TMP"' EXIT
55+
56+
# Strip any existing Claude co-author line. Character classes (not the /I
57+
# flag) because BSD sed on macOS does not support case-insensitive match.
58+
# Matches: "Co-authored-by: Claude", "co-authored-by: Claude Opus 4.6",
59+
# "Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>", etc.
60+
sed -E '/^[Cc]o-[Aa]uthored-[Bb]y:[[:space:]]*Claude.*/d' "$COMMIT_MSG_FILE" > "$TMP"
61+
62+
# Append the canonical trailer. interpret-trailers handles the blank-line
63+
# separator between body and trailer block per git convention. Using
64+
# addIfDifferent as belt-and-suspenders — we already stripped Claude lines
65+
# above, so this will always add ours; the flag just guards against future
66+
# edits to the strip regex.
67+
git interpret-trailers \
68+
--if-exists addIfDifferent \
69+
--trailer "$TRAILER" \
70+
"$TMP" > "$COMMIT_MSG_FILE"

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed — Delegation framing aligned with non-strict subset behavior (2026-04-15)
11+
12+
- Comments and docs in 9 places claimed delegation enforces strict narrowing ("strict subset", "only narrow", "narrower-scoped"). The actual `authz.ScopeIsSubset` is a non-strict containment check — equal scopes pass, and same-scope delegation is a deliberate pattern (e.g., fan-out to workers carrying the parent's full authority, verified by SDK acceptance Story 8). Wording corrected across `internal/deleg/deleg_svc.go`, `internal/authz/scope.go`, `README.md`, `docs/security-topology.md`, `docs/architecture.md`, `docs/roles.md`, `docs/common-tasks.md`, `docs/integration-patterns.md`, and the `docs/diagrams/security-topology.svg` callout label. Two source-file docstrings now carry a back-reference to issue #41 explaining why this is not a strict-subset check. Closes #41.
13+
1014
### Added — FAQ from community feedback (2026-04-13)
1115

1216
- New `docs/faq.md` — real questions from practitioners evaluating AgentWrit. Covers identity vs credential exchange, OIDC (enterprise), scope model, scope drift detection (roadmap), SDK status, restart/HA behavior, demo status, and licensing.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ Traditional IAM was built for humans and long-running services — not for AI ag
3636
| Traditional IAM for agents | AgentWrit |
3737
|---|---|
3838
| Agents get static API keys or service account credentials designed for long-running services | Each agent requests a token scoped to one task |
39-
| Credentials are over-permissioned because scoping per-task is manual and fragile | Scope attenuation is automatic — permissions only narrow, never expand |
39+
| Credentials are over-permissioned because scoping per-task is manual and fragile | Scope attenuation is automatic — permissions cannot widen, only equal or narrower |
4040
| Leaked credential exposes everything the service account can access | Leaked token exposes one task, already expiring in minutes |
4141
| Revoking a static key means rotating it everywhere it's used | Revocation is instant at 4 levels — token, agent, task, or delegation chain |
4242
| No visibility into which agent used which credential for which task | Every credential event is audited per-agent, per-task in a tamper-evident hash chain |
43-
| No native concept of agent-to-agent delegation | Delegation is built in — Agent A can delegate narrower-scoped tokens to Agent B with full chain tracking |
43+
| No native concept of agent-to-agent delegation | Delegation is built in — Agent A can delegate scope-attenuated tokens to Agent B (equal or narrower) with full chain tracking |
4444

4545
> **What the audit trail covers:** The broker logs credential lifecycle events — issue, renew, revoke, delegate, release, auth failures, and scope violations. It does not see what the agent does with the token at the resource server.
4646

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ sequenceDiagram
209209

210210
### Delegation Flow
211211

212-
Agent A delegates a narrower-scoped token to Agent B:
212+
Agent A delegates a scope-attenuated token to Agent B (equal or narrower scope; widening is rejected):
213213

214214
```mermaid
215215
sequenceDiagram

docs/common-tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ import requests
678678
BROKER = "http://localhost:8080"
679679

680680
def delegate_token(broker, my_token, delegate_agent_id, scope, ttl=60):
681-
"""Delegate a narrower-scoped token to another agent."""
681+
"""Delegate a scope-attenuated token to another agent (equal or narrower)."""
682682
resp = requests.post(
683683
f"{broker}/v1/delegate",
684684
headers={"Authorization": f"Bearer {my_token}"},
@@ -839,7 +839,7 @@ try {
839839
| Status | Meaning | Action |
840840
|--------|---------|--------|
841841
| 400 | Invalid request (bad format, missing fields) | Verify `delegate_to` is a valid SPIFFE ID and `scope` is an array |
842-
| 403 | Scope escalation or widening attempted | Ensure delegated scope is a strict subset of your scope |
842+
| 403 | Scope escalation or widening attempted | Ensure delegated scope does not widen your scope (equal or narrower is accepted) |
843843
| 404 | Delegate agent not found in broker | Verify the agent's SPIFFE ID is correct and it has registered |
844844
| 401 | Your token is invalid or expired | Renew or re-register to get a fresh token |
845845

docs/diagrams/security-topology.svg

Lines changed: 2 additions & 2 deletions
Loading

docs/integration-patterns.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2092,7 +2092,7 @@ When implementing AgentWrit patterns, verify:
20922092
### Scope and Delegation
20932093

20942094
- [ ] Scope ceilings are enforced at the broker level
2095-
- [ ] Delegation always narrows scope (never escalates)
2095+
- [ ] Delegation does not widen scope (equal or narrower)
20962096
- [ ] Delegation depth is limited (maximum 5 hops)
20972097
- [ ] Scope format is validated: `action:resource:identifier`
20982098
- [ ] Wildcard scope (`*`) is used narrowly and intentionally

docs/roles.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ write:logs:agent-run-42
9494
| `POST /v1/token/validate` | External services check this token to decide if the agent is authorized |
9595
| `POST /v1/token/renew` | Extend the session — same scope, same original TTL, old token revoked first |
9696
| `POST /v1/token/release` | Self-revoke when the task is done |
97-
| `POST /v1/delegate` | Create a narrower-scoped token for another registered agent |
97+
| `POST /v1/delegate` | Create a scope-attenuated token (equal or narrower) for another registered agent |
9898

9999
**What the agent cannot do:** call any admin or app endpoint. It has no `admin:*` or `app:*` scopes. The broker enforces this.
100100

docs/security-topology.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Scopes only move in one direction: down. Every boundary is enforced at issuance
2727

2828
- **Challenge-response** — Ed25519 keypair per agent instance. No shared secrets at the agent level.
2929
- **Hash-chain audit** — tamper-evident trail with 24 event types. Each record hashes the previous.
30-
- **Scope attenuation** — scopes can only narrow, never escalate. Delegation preserves the original principal.
30+
- **Scope attenuation** — scopes cannot widen; equal or narrower is accepted. Delegation preserves the original principal.
3131
- **Token TTL** — default 5 minutes, max 24 hours (configurable). Per-app override available. Revocable at 4 levels.
3232

3333
---

internal/authz/scope.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,10 @@ func scopeCovers(requested, allowed string) bool {
7272
// least one scope in allowed. A scope is covered when its action and
7373
// resource match and either the identifiers are equal or the allowed
7474
// identifier is the wildcard "*". This enforces the attenuation rule:
75-
// scopes can only narrow, never expand.
75+
// requested scopes cannot widen allowed scopes. Equal is accepted
76+
// (same-scope delegation is a deliberate pattern); narrower is accepted;
77+
// broader is rejected. See issue #41 for why this is not a strict-subset
78+
// check.
7679
func ScopeIsSubset(requested, allowed []string) bool {
7780
for _, req := range requested {
7881
covered := false

0 commit comments

Comments
 (0)