capforge 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
capforge/cli/main.py ADDED
@@ -0,0 +1,347 @@
1
+ """Command-line interface for CapForge (ACM protocol).
2
+
3
+ Usage
4
+ -----
5
+
6
+ capforge keygen --output ./keys/my-key
7
+ capforge create --spiffe-id spiffe://org/agent/bot \\
8
+ --sponsor user@org.com \\
9
+ --capability kb:read \\
10
+ --key-file keys/my-key.pem \\
11
+ --output manifest.json
12
+ capforge verify manifest.json --trusted-roots root.pub
13
+ capforge info manifest.json
14
+ capforge delegate --parent manifest.json \\
15
+ --child-spiffe-id spiffe://org/agent/sub \\
16
+ --capability kb:read
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ import click
27
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
28
+
29
+ from capforge import ACM, Capability, Constraints, __version__
30
+ from capforge._crypto import (
31
+ generate_private_key,
32
+ write_key_pair,
33
+ )
34
+ from capforge._exceptions import ACMError
35
+
36
+ # ── keygen ────────────────────────────────────────────────────────
37
+
38
+
39
+ @click.command()
40
+ @click.option(
41
+ "--output",
42
+ "-o",
43
+ default="capforge-key",
44
+ show_default=True,
45
+ help="Path prefix for key files (creates {prefix}.pem and {prefix}.pub).",
46
+ )
47
+ def keygen(output: str) -> None:
48
+ """Generate a new Ed25519 key pair for signing ACM documents."""
49
+ priv_key = generate_private_key()
50
+ priv_path, pub_path = write_key_pair(priv_key, path_prefix=output)
51
+ click.echo(f"Private key: {priv_path}")
52
+ click.echo(f"Public key: {pub_path}")
53
+ click.echo("Keep the private key secure. Never share it.")
54
+
55
+
56
+ # ── create ────────────────────────────────────────────────────────
57
+
58
+
59
+ @click.command()
60
+ @click.option("--spiffe-id", required=True, help="SPIFFE ID of the agent.")
61
+ @click.option("--sponsor", required=True, help="Email or SPIFFE ID of the human sponsor.")
62
+ @click.option(
63
+ "--capability",
64
+ "-c",
65
+ multiple=True,
66
+ required=True,
67
+ help="Capability in 'resource:action' format (can be repeated).",
68
+ )
69
+ @click.option(
70
+ "--constraint",
71
+ "-C",
72
+ multiple=True,
73
+ help="Constraint in 'key=value' format (can be repeated).",
74
+ )
75
+ @click.option("--model", help="LLM model name (e.g., 'gpt-5').")
76
+ @click.option("--provider", help="Model provider (e.g., 'openai').")
77
+ @click.option(
78
+ "--key-file",
79
+ type=click.Path(exists=True, dir_okay=False),
80
+ help="Path to Ed25519 private key PEM file for signing.",
81
+ )
82
+ @click.option(
83
+ "--output",
84
+ "-o",
85
+ type=click.Path(dir_okay=False),
86
+ help="Write the ACM document to a file instead of stdout.",
87
+ )
88
+ @click.option("--ttl", default=3600, show_default=True, help="Time-to-live in seconds.")
89
+ @click.option("--not-before", help="ISO 8601 timestamp for not_before (default: now).")
90
+ @click.option("--metadata", help="JSON string of metadata to attach.")
91
+ def create(
92
+ spiffe_id: str,
93
+ sponsor: str,
94
+ capability: tuple[str, ...],
95
+ constraint: tuple[str, ...],
96
+ model: str | None,
97
+ provider: str | None,
98
+ key_file: str | None,
99
+ output: str | None,
100
+ ttl: int,
101
+ not_before: str | None,
102
+ metadata: str | None,
103
+ ) -> None:
104
+ """Create a new ACM document, optionally sign it, and write to stdout or file."""
105
+ caps: list[Capability] = []
106
+ for cap_str in capability:
107
+ parts = cap_str.split(":", 1)
108
+ if len(parts) != 2:
109
+ click.echo(
110
+ f"Invalid capability: {cap_str!r} (expected 'resource:action')",
111
+ err=True,
112
+ )
113
+ sys.exit(1)
114
+ caps.append(Capability(resource=parts[0], action=parts[1]))
115
+
116
+ cons: dict[str, object] = {}
117
+ for c_str in constraint:
118
+ kv = c_str.split("=", 1)
119
+ if len(kv) == 2:
120
+ key, raw = kv[0], kv[1]
121
+ try:
122
+ cons[key] = float(raw) if "." in raw else int(raw)
123
+ except ValueError:
124
+ cons[key] = raw
125
+
126
+ meta: dict[str, object] | None = None
127
+ if metadata:
128
+ try:
129
+ meta = json.loads(metadata)
130
+ except json.JSONDecodeError as exc:
131
+ click.echo(f"Invalid metadata JSON: {exc}", err=True)
132
+ sys.exit(1)
133
+
134
+ nb: datetime | None = None
135
+ if not_before:
136
+ try:
137
+ nb = datetime.fromisoformat(not_before)
138
+ except ValueError as exc:
139
+ click.echo(f"Invalid not_before: {exc}", err=True)
140
+ sys.exit(1)
141
+
142
+ constraints_obj = Constraints.model_validate(cons) if cons else None
143
+
144
+ manifest = ACM.create(
145
+ spiffe_id=spiffe_id,
146
+ sponsor=sponsor,
147
+ capabilities=caps,
148
+ constraints=constraints_obj,
149
+ ttl=ttl,
150
+ not_before=nb,
151
+ model=model,
152
+ provider=provider,
153
+ metadata=meta,
154
+ )
155
+
156
+ if key_file:
157
+ manifest.sign(key_file)
158
+
159
+ result = manifest.to_json()
160
+ if output:
161
+ manifest.save(output)
162
+ click.echo(f"Written to {output}")
163
+ else:
164
+ click.echo(result)
165
+
166
+
167
+ # ── verify ────────────────────────────────────────────────────────
168
+
169
+
170
+ @click.command()
171
+ @click.argument("manifest_path", type=click.Path(exists=True, dir_okay=False))
172
+ @click.option(
173
+ "--trusted-roots",
174
+ type=click.Path(exists=True, dir_okay=False),
175
+ multiple=True,
176
+ help="Public key PEM file(s) of trusted issuers (can be repeated).",
177
+ )
178
+ def verify(manifest_path: str, trusted_roots: tuple[str, ...]) -> None:
179
+ """Verify an ACM document — temporal, structural, and optionally signature."""
180
+ manifest = ACM.load(manifest_path)
181
+
182
+ trusted_key_list: list[str | Path | Ed25519PublicKey] = (
183
+ [Path(p) for p in trusted_roots] if trusted_roots else []
184
+ )
185
+
186
+ try:
187
+ if trusted_key_list:
188
+ manifest.verify(trusted_keys=trusted_key_list)
189
+ click.echo("Verification PASSED (signature valid)")
190
+ else:
191
+ manifest.verify()
192
+ click.echo("Verification PASSED (temporal + structural)")
193
+
194
+ click.echo(f" Agent: {manifest.spiffe_id}")
195
+ click.echo(f" Sponsor: {manifest.sponsor}")
196
+ click.echo(f" Capabilities: {len(manifest.capabilities_list)}")
197
+ click.echo(f" Expires at: {manifest.expires_at}")
198
+ if manifest.signature:
199
+ click.echo(f" Signature: {manifest.signature[:32]}...")
200
+ if manifest.delegation_chain:
201
+ click.echo(f" Delegation: {len(manifest.delegation_chain)} hops")
202
+ except ACMError as exc:
203
+ click.echo(f"Verification FAILED: {exc}", err=True)
204
+ sys.exit(1)
205
+
206
+
207
+ # ── info ──────────────────────────────────────────────────────────
208
+
209
+
210
+ @click.command()
211
+ @click.argument("manifest_path", type=click.Path(exists=True, dir_okay=False))
212
+ def info(manifest_path: str) -> None:
213
+ """Display human-readable information about an ACM document."""
214
+ manifest = ACM.load(manifest_path)
215
+
216
+ click.echo(f"ACM Version: {manifest.acm_version}")
217
+ click.echo(f"Agent SPIFFE ID: {manifest.spiffe_id}")
218
+ if manifest.agent_model:
219
+ click.echo(f"Model: {manifest.agent_model}")
220
+ if manifest.agent_provider:
221
+ click.echo(f"Provider: {manifest.agent_provider}")
222
+ click.echo(f"Human Sponsor: {manifest.sponsor}")
223
+ click.echo(f"Issuer: {manifest.issuer}")
224
+ click.echo(f"Expires At: {manifest.expires_at}")
225
+ if manifest.not_before:
226
+ click.echo(f"Not Before: {manifest.not_before}")
227
+ click.echo(f"Capabilities ({len(manifest.capabilities_list)}):")
228
+ for cap in manifest.capabilities_list:
229
+ line = f" - {cap.resource}:{cap.action}"
230
+ if cap.constraints:
231
+ line += f" [{cap.constraints}]"
232
+ click.echo(line)
233
+ if manifest.constraints_obj:
234
+ click.echo(f"Constraints: {manifest.constraints_obj.model_dump(exclude_none=True)}")
235
+ if manifest.delegation_chain:
236
+ hops = len(manifest.delegation_chain)
237
+ click.echo(f"Delegation Chain ({hops} hops):")
238
+ for entry in manifest.delegation_chain:
239
+ click.echo(f" -> {entry}")
240
+ click.echo(f"Has Signature: {manifest.signature is not None}")
241
+ click.echo(f"Valid Now: {manifest.is_valid()}")
242
+
243
+
244
+ # ── validate ──────────────────────────────────────────────────────
245
+
246
+
247
+ @click.command()
248
+ @click.argument("manifest_path", type=click.Path(exists=True, dir_okay=False))
249
+ def validate(manifest_path: str) -> None:
250
+ """Validate an ACM document (temporal + structural, no signature check)."""
251
+ manifest = ACM.load(manifest_path)
252
+ try:
253
+ manifest.verify()
254
+ click.echo("Validation PASSED (temporal + structural)")
255
+ click.echo(f" Agent: {manifest.spiffe_id}")
256
+ click.echo(f" Capabilities: {len(manifest.capabilities_list)}")
257
+ click.echo(f" Expires at: {manifest.expires_at}")
258
+ except ACMError as exc:
259
+ click.echo(f"Validation FAILED: {exc}", err=True)
260
+ sys.exit(1)
261
+
262
+
263
+ # ── delegate ──────────────────────────────────────────────────────
264
+
265
+
266
+ @click.command()
267
+ @click.option(
268
+ "--parent",
269
+ required=True,
270
+ type=click.Path(exists=True, dir_okay=False),
271
+ help="Parent ACM manifest JSON file.",
272
+ )
273
+ @click.option("--child-spiffe-id", required=True, help="SPIFFE ID of the child agent.")
274
+ @click.option(
275
+ "--capability",
276
+ "-c",
277
+ multiple=True,
278
+ required=True,
279
+ help="Capability to delegate (can be repeated). Must be subset of parent's.",
280
+ )
281
+ @click.option(
282
+ "--key-file",
283
+ type=click.Path(exists=True, dir_okay=False),
284
+ help="Path to the parent's private key to sign the delegation.",
285
+ )
286
+ @click.option(
287
+ "--output", "-o", type=click.Path(dir_okay=False), help="Write delegated ACM to a file."
288
+ )
289
+ @click.option("--ttl", default=3600, show_default=True, help="Time-to-live in seconds.")
290
+ def delegate(
291
+ parent: str,
292
+ child_spiffe_id: str,
293
+ capability: tuple[str, ...],
294
+ key_file: str | None,
295
+ output: str | None,
296
+ ttl: int,
297
+ ) -> None:
298
+ """Create a delegated ACM document with narrowed scope."""
299
+ parent_manifest = ACM.load(parent)
300
+
301
+ caps: list[Capability] = []
302
+ for cap_str in capability:
303
+ parts = cap_str.split(":", 1)
304
+ if len(parts) != 2:
305
+ click.echo(
306
+ f"Invalid capability: {cap_str!r} (expected 'resource:action')",
307
+ err=True,
308
+ )
309
+ sys.exit(1)
310
+ caps.append(Capability(resource=parts[0], action=parts[1]))
311
+
312
+ try:
313
+ child = parent_manifest.delegate(
314
+ child_spiffe_id=child_spiffe_id,
315
+ capabilities=caps,
316
+ ttl=ttl,
317
+ key=key_file,
318
+ )
319
+ result = child.to_json()
320
+ if output:
321
+ child.save(output)
322
+ click.echo(f"Written to {output}")
323
+ else:
324
+ click.echo(result)
325
+ except (ValueError, ACMError) as exc:
326
+ click.echo(f"Delegation FAILED: {exc}", err=True)
327
+ sys.exit(1)
328
+
329
+
330
+ # ── CLI group ─────────────────────────────────────────────────────
331
+
332
+
333
+ @click.group()
334
+ @click.version_option(version=__version__, prog_name="capforge")
335
+ def cli() -> None:
336
+ """CapForge — Agent Capability Manifest (ACM) management."""
337
+
338
+
339
+ cli.add_command(keygen)
340
+ cli.add_command(create)
341
+ cli.add_command(verify)
342
+ cli.add_command(info)
343
+ cli.add_command(validate)
344
+ cli.add_command(delegate)
345
+
346
+ if __name__ == "__main__":
347
+ cli()
capforge/py.typed ADDED
File without changes