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/__init__.py +50 -0
- capforge/_acm.py +491 -0
- capforge/_crypto.py +149 -0
- capforge/_delegation.py +83 -0
- capforge/_exceptions.py +21 -0
- capforge/_model.py +176 -0
- capforge/_policy.py +341 -0
- capforge/_validator.py +71 -0
- capforge/_version.py +3 -0
- capforge/cli/__init__.py +1 -0
- capforge/cli/main.py +347 -0
- capforge/py.typed +0 -0
- capforge-0.4.0.dist-info/METADATA +568 -0
- capforge-0.4.0.dist-info/RECORD +21 -0
- capforge-0.4.0.dist-info/WHEEL +5 -0
- capforge-0.4.0.dist-info/entry_points.txt +2 -0
- capforge-0.4.0.dist-info/licenses/LICENSE +201 -0
- capforge-0.4.0.dist-info/top_level.txt +2 -0
- capforge_langgraph/__init__.py +36 -0
- capforge_langgraph/_tool_wrapper.py +219 -0
- capforge_langgraph/pyproject.toml +35 -0
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
|