capwallet.gno
7.52 Kb · 248 lines
1// Package capwallet issues narrow, bounded, revocable capabilities to
2// agents — instead of handing an agent a wallet key and hoping its prompt
3// stays safe.
4//
5// The demo makes three ideas that are usually conflated stand apart:
6//
7// - identity — "this is agent Percy"
8// - authorization — "Percy may call this function on this realm"
9// - approval — "Percy may do it at most N times, for ≤ M coins,
10// before block H, and I can revoke it at any moment"
11//
12// A capability is the third thing: a least-privilege grant with a ceiling,
13// an expiry, a use counter and a kill switch. Other realms consult
14// Authorized() before honoring an agent action; the granter revokes with
15// one call the instant something looks wrong.
16package capwallet
17
18import (
19 "chain"
20 "chain/runtime"
21 "strings"
22
23 "gno.land/p/nt/ufmt/v0"
24)
25
26// Use records one exercise of a capability.
27type Use struct {
28 By address
29 Coins int64
30 Height int64
31}
32
33// Capability is a least-privilege grant from a granter to a principal.
34type Capability struct {
35 ID uint64
36 Granter address
37 Principal address // the only address that may exercise it
38 TargetRealm string // realm the capability is scoped to
39 Function string // function the capability authorizes
40 MaxCoins int64 // per-exercise ceiling in ugnot (0 = none allowed)
41 ValidUntil int64 // block height after which it expires (0 = never)
42 RemainingUses uint32 // exercises left (0 = exhausted)
43 ArgsPolicy string // human-readable note on allowed arguments
44 Revoked bool
45 GrantedAt int64
46 Uses []Use
47}
48
49var caps []*Capability // index 0 => ID 1
50
51// Grant issues a capability. The caller becomes its granter and can revoke
52// it later. validUntil is an absolute block height (0 = no expiry).
53func Grant(
54 cur realm,
55 principal address,
56 targetRealm, function string,
57 maxCoins, validUntil int64,
58 uses uint32,
59 argsPolicy string,
60) uint64 {
61 assert(targetRealm != "" && function != "", "target realm and function required")
62 assert(uses > 0, "must grant at least one use")
63
64 id := uint64(len(caps)) + 1
65 c := &Capability{
66 ID: id,
67 Granter: cur.Previous().Address(),
68 Principal: principal,
69 TargetRealm: targetRealm,
70 Function: function,
71 MaxCoins: maxCoins,
72 ValidUntil: validUntil,
73 RemainingUses: uses,
74 ArgsPolicy: argsPolicy,
75 GrantedAt: runtime.ChainHeight(),
76 }
77 caps = append(caps, c)
78 chain.Emit("CapabilityGranted",
79 "id", ufmt.Sprintf("%d", id),
80 "principal", principal.String(),
81 "target", targetRealm+"."+function,
82 )
83 return id
84}
85
86// Exercise consumes one use of a capability. Only the principal may call it,
87// and every bound is checked: not revoked, not expired, uses remaining, and
88// coins within the ceiling. On success the use is recorded and the counter
89// decremented.
90func Exercise(cur realm, id uint64, coins int64) {
91 c := mustGet(id)
92 caller := cur.Previous().Address()
93 assert(caller == c.Principal, "caller is not the capability principal")
94 assertUsable(c)
95 assert(coins <= c.MaxCoins, ufmt.Sprintf("coins %d exceed capability ceiling %d", coins, c.MaxCoins))
96
97 c.RemainingUses--
98 c.Uses = append(c.Uses, Use{By: caller, Coins: coins, Height: runtime.ChainHeight()})
99 chain.Emit("CapabilityExercised",
100 "id", ufmt.Sprintf("%d", id),
101 "coins", ufmt.Sprintf("%d", coins),
102 "remaining", ufmt.Sprintf("%d", c.RemainingUses),
103 )
104}
105
106// Revoke disables a capability immediately. Only the granter may revoke.
107func Revoke(cur realm, id uint64) {
108 c := mustGet(id)
109 assert(cur.Previous().Address() == c.Granter, "only the granter may revoke")
110 c.Revoked = true
111 chain.Emit("CapabilityRevoked", "id", ufmt.Sprintf("%d", id))
112}
113
114// ---- read-only API for gating realms ----
115
116// Authorized reports whether principal may exercise capability id for the
117// given coin amount right now — the check a target realm runs before acting.
118// It never mutates state or consumes a use.
119func Authorized(id uint64, principal address, coins int64) bool {
120 if id < 1 || id > uint64(len(caps)) {
121 return false
122 }
123 c := caps[id-1]
124 if c.Principal != principal || !usable(c) {
125 return false
126 }
127 return coins <= c.MaxCoins
128}
129
130// Get returns a copy of a capability.
131func Get(id uint64) Capability { return *mustGet(id) }
132
133// Count returns the number of capabilities issued.
134func Count() int { return len(caps) }
135
136// ---- internal ----
137
138func usable(c *Capability) bool {
139 if c.Revoked || c.RemainingUses == 0 {
140 return false
141 }
142 if c.ValidUntil != 0 && runtime.ChainHeight() > c.ValidUntil {
143 return false
144 }
145 return true
146}
147
148func assertUsable(c *Capability) {
149 assert(!c.Revoked, "capability revoked")
150 assert(c.RemainingUses > 0, "capability exhausted")
151 assert(c.ValidUntil == 0 || runtime.ChainHeight() <= c.ValidUntil, "capability expired")
152}
153
154func mustGet(id uint64) *Capability {
155 assert(id >= 1 && id <= uint64(len(caps)), "unknown capability")
156 return caps[id-1]
157}
158
159func assert(cond bool, msg string) {
160 if !cond {
161 panic(msg)
162 }
163}
164
165// Render shows all capabilities, or one capability's detail + usage at :<id>.
166func Render(path string) string {
167 if path == "" {
168 return renderIndex()
169 }
170 return renderCap(parseID(path))
171}
172
173func renderIndex() string {
174 var sb strings.Builder
175 sb.WriteString("# Capability Wallet\n\n")
176 sb.WriteString("_Don't give an agent a wallet. Give it a capability._\n\n")
177 if len(caps) == 0 {
178 sb.WriteString("_No capabilities granted yet._\n")
179 return sb.String()
180 }
181 sb.WriteString("| # | Principal | Scope | Ceiling | Uses left | State |\n")
182 sb.WriteString("|---|---|---|---|---|---|\n")
183 for _, c := range caps {
184 sb.WriteString(ufmt.Sprintf("| [%d](/r/g1manfred47kzduec920z88wfr64ylksmdcedlf5/agents/capwallet:%d) | %s | `%s.%s` | %d ugnot | %d | %s |\n",
185 c.ID, c.ID, short(c.Principal), c.TargetRealm, c.Function, c.MaxCoins, c.RemainingUses, state(c)))
186 }
187 return sb.String()
188}
189
190func renderCap(id uint64) string {
191 c := mustGet(id)
192 var sb strings.Builder
193 sb.WriteString(ufmt.Sprintf("# Capability #%d\n\n", c.ID))
194 sb.WriteString(ufmt.Sprintf("- **State:** %s\n", state(c)))
195 sb.WriteString(ufmt.Sprintf("- **Granter:** %s\n", c.Granter.String()))
196 sb.WriteString(ufmt.Sprintf("- **Principal:** %s\n", c.Principal.String()))
197 sb.WriteString(ufmt.Sprintf("- **Scope:** `%s.%s`\n", c.TargetRealm, c.Function))
198 sb.WriteString(ufmt.Sprintf("- **Per-use ceiling:** %d ugnot\n", c.MaxCoins))
199 if c.ValidUntil == 0 {
200 sb.WriteString("- **Expires:** never\n")
201 } else {
202 sb.WriteString(ufmt.Sprintf("- **Expires at height:** %d (now %d)\n", c.ValidUntil, runtime.ChainHeight()))
203 }
204 sb.WriteString(ufmt.Sprintf("- **Uses remaining:** %d\n", c.RemainingUses))
205 sb.WriteString(ufmt.Sprintf("- **Args policy:** %s\n\n", c.ArgsPolicy))
206
207 sb.WriteString(ufmt.Sprintf("## Exercise log (%d)\n\n", len(c.Uses)))
208 if len(c.Uses) == 0 {
209 sb.WriteString("_Never exercised._\n")
210 return sb.String()
211 }
212 sb.WriteString("| By | Coins | Height |\n|---|---|---|\n")
213 for _, u := range c.Uses {
214 sb.WriteString(ufmt.Sprintf("| %s | %d | %d |\n", short(u.By), u.Coins, u.Height))
215 }
216 return sb.String()
217}
218
219func state(c *Capability) string {
220 if c.Revoked {
221 return "🚫 revoked"
222 }
223 if c.RemainingUses == 0 {
224 return "· exhausted"
225 }
226 if c.ValidUntil != 0 && runtime.ChainHeight() > c.ValidUntil {
227 return "⏳ expired"
228 }
229 return "✅ live"
230}
231
232func parseID(s string) uint64 {
233 var n uint64
234 for i := 0; i < len(s); i++ {
235 ch := s[i]
236 assert(ch >= '0' && ch <= '9', "invalid capability path: "+s)
237 n = n*10 + uint64(ch-'0')
238 }
239 return n
240}
241
242func short(a address) string {
243 s := a.String()
244 if len(s) <= 12 {
245 return s
246 }
247 return s[:8] + "…" + s[len(s)-4:]
248}