@passioncode-ai/fabric-agent-adapter 0.11.1 → 0.12.0
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.
- package/CHANGELOG.md +42 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +17 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +84 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +135 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +7 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
The contract pin moves to Fabric Agent Contract `main` `2eb91c0` (the merge of contract PR #45: DEC-0041…DEC-0045).
|
|
6
|
+
Bundles issued under the DEC-0039, DEC-0036, DEC-0035, DEC-0033, DEC-0031, DEC-0024, DEC-0021, DEC-0020 and DEC-0019
|
|
7
|
+
revisions stay valid (FAA-24).
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Default contract pin `8e49fbd` → `2eb91c00e852d9c31d804310d38fad91589fcbac`.** `8e49fbd` (DEC-0039) joins
|
|
12
|
+
`SUPPORTED_CONTRACT_COMMITS`. The schemas between add only:
|
|
13
|
+
- `session-handoff/1` (DEC-0042);
|
|
14
|
+
- `degraded[].setup` on the well-known document and `run.mcp.stdio.scope` on the manifest and the provider entry
|
|
15
|
+
(DEC-0044);
|
|
16
|
+
- a tighter delegation token (DEC-0045).
|
|
17
|
+
|
|
18
|
+
The provider manifest and the MCP, A2A and local-runner profiles stay valid as written. The compiled-schema arms at
|
|
19
|
+
`2ce3922` and `2eb91c0` agree.
|
|
20
|
+
- **The delegation verifiers follow DEC-0045** in both kits:
|
|
21
|
+
- The trusted `issuer` is required. Without it the answer is `issuer-unavailable` / `no-trusted-issuer`.
|
|
22
|
+
- Every published-key problem except an unknown `kid` is the issuer's, including `key-not-for-signing`. A
|
|
23
|
+
small-order key is refused as `issuer-unavailable` / `small-order-key` before any verification; 0.11.1 answered
|
|
24
|
+
`bad-signature`.
|
|
25
|
+
- DL-2's types are checked strictly: URIs, `AGENT_ID` actors, session and task ids, the `scope` and `jti`
|
|
26
|
+
grammar, exactly one grant, effects named once, the `spend` shape, and `nbf` checked with the types.
|
|
27
|
+
`approval.method: device` is removed.
|
|
28
|
+
- A signed token with no `act` is refused as `malformed-chain`. The contract's own verifier throws on it, and the
|
|
29
|
+
contract was told.
|
|
30
|
+
- **Breaking for a caller of the gate:** `delegation_gate` / `delegationGate` require `issuer`, and
|
|
31
|
+
`verify_delegation` / `verify_approval` take it as a required argument.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Trust tiers (DL-25, DL-26):** `gate(tool, headers, tier="T2")` and `McpToolServer.handle(..., tier=)`. A T0 or T1
|
|
36
|
+
caller is never required to present a token; a present one is verified at every tier.
|
|
37
|
+
- **`refusal_from_denial` / `refusalFromDenial`:** the callee's answer after the meter denies a grant check (DL-21
|
|
38
|
+
step 6).
|
|
39
|
+
- **The contract's canonical vectors** (DEC-0043) run against both `jcs` ports from `test/fixtures/contract/canonical.json`.
|
|
40
|
+
- **`fabric_provider.py` accepts `run.mcp.stdio.scope`** (`project` or `on-demand`) and refuses `session`
|
|
41
|
+
(FAC-SEM-056, LC-19). The writer does not set it until Fabric reads it.
|
|
42
|
+
- `degraded[].setup` (DEC-0044) is documented in `references/protocol.md`. The kits pass entries through unchanged.
|
|
43
|
+
- Scenario evals came first: `local-caller-needs-no-token` and `stdio-provider-scope`.
|
|
44
|
+
|
|
3
45
|
## 0.11.1 - 2026-10-12
|
|
4
46
|
|
|
5
47
|
0.11.0 plus one fix. The `v0.11.0` tag was refused by the release's own validation and never published: on Node 22,
|
package/README.md
CHANGED
|
@@ -202,7 +202,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
|
|
|
202
202
|
The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
203
203
|
|
|
204
204
|
- version: `0.1.0`
|
|
205
|
-
- commit: `
|
|
205
|
+
- commit: `2eb91c00e852d9c31d804310d38fad91589fcbac`
|
|
206
206
|
|
|
207
207
|
Every other mention of the default contract revision — this section, the skills' metadata, the
|
|
208
208
|
skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
|
|
@@ -229,8 +229,8 @@ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/curren
|
|
|
229
229
|
```
|
|
230
230
|
|
|
231
231
|
Without both environment variables, those tests explicitly skip as `NOT_RUN`.
|
|
232
|
-
This source checkout is 0.
|
|
233
|
-
were never released); 0.
|
|
232
|
+
This source checkout is 0.12.0. 0.11.1 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
|
|
233
|
+
were never released); 0.12.0 is published by its `v0.12.0` tag. Source delivery does not update installed skills or live sessions.
|
|
234
234
|
|
|
235
235
|
## License
|
|
236
236
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.12.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.12.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "2eb91c00e852d9c31d804310d38fad91589fcbac"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Adapting projects to Fabric
|
|
@@ -70,7 +70,7 @@ Use exactly:
|
|
|
70
70
|
|
|
71
71
|
- contract version `0.1.0`;
|
|
72
72
|
- repository `https://github.com/passioncode-ai/fabric-agent-contract`;
|
|
73
|
-
- commit `
|
|
73
|
+
- commit `2eb91c00e852d9c31d804310d38fad91589fcbac`.
|
|
74
74
|
|
|
75
75
|
This is the default for new bundles. When checking an issued legacy bundle, preserve its
|
|
76
76
|
explicit supported lock and use that exact clean checkout. Never silently upgrade or
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md
CHANGED
|
@@ -32,7 +32,10 @@ python3 scripts/fabric_provider.py validate ~/Library/Application\ Support/ai.pa
|
|
|
32
32
|
python3 scripts/fabric_provider.py remove example-agent # the uninstaller
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`--stdio` takes the rest of the line as the argument array; there is no shell string.
|
|
35
|
+
`--stdio` takes the rest of the line as the argument array; there is no shell string. An entry may carry
|
|
36
|
+
`run.mcp.stdio.scope` (DEC-0044): `project` (started for the sessions of a project that binds it) or `on-demand`;
|
|
37
|
+
`session` — a server in every coding session — is refused (FAC-SEM-056, LC-19). The validator accepts the field; the
|
|
38
|
+
writer does not set it until Fabric reads it.
|
|
36
39
|
|
|
37
40
|
It refuses, with one sentence that never quotes a value:
|
|
38
41
|
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -16,10 +16,10 @@ from urllib.parse import urlparse
|
|
|
16
16
|
|
|
17
17
|
CONTRACT_VERSION = "0.1.0"
|
|
18
18
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
19
|
-
CONTRACT_COMMIT = "
|
|
19
|
+
CONTRACT_COMMIT = "2eb91c00e852d9c31d804310d38fad91589fcbac"
|
|
20
20
|
# #region supported-contract-revisions — docs: README.md#contract-pin
|
|
21
21
|
# Only this declaration may name a legacy revision in live files.
|
|
22
|
-
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
22
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
23
23
|
# #endregion supported-contract-revisions
|
|
24
24
|
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
25
25
|
MCP_REVISION = "2026-07-28"
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py
CHANGED
|
@@ -116,8 +116,11 @@ def _run_problems(run: Any) -> List[str]:
|
|
|
116
116
|
return [] if match and 1 <= int(match.group(1)) <= 65535 else ["run.mcp.url is http://127.0.0.1:<port>/mcp"]
|
|
117
117
|
stdio = mcp["stdio"]
|
|
118
118
|
problems: List[str] = []
|
|
119
|
-
if not isinstance(stdio, dict) or not set(stdio) <= {"command", "env"} or "command" not in stdio:
|
|
120
|
-
return ["run.mcp.stdio is {command, env?}"]
|
|
119
|
+
if not isinstance(stdio, dict) or not set(stdio) <= {"command", "env", "scope"} or "command" not in stdio:
|
|
120
|
+
return ["run.mcp.stdio is {command, env?, scope?}"]
|
|
121
|
+
# DEC-0044, LC-19: where a host starts the server. A server started in every session is refused (FAC-SEM-056).
|
|
122
|
+
if "scope" in stdio and stdio["scope"] not in ("project", "on-demand"):
|
|
123
|
+
problems.append("run.mcp.stdio.scope is project or on-demand; a stdio server started in every session is refused (FAC-SEM-056, LC-19)")
|
|
121
124
|
command = stdio["command"]
|
|
122
125
|
if not isinstance(command, list) or not 1 <= len(command) <= 32 or not all(isinstance(a, str) and a for a in command):
|
|
123
126
|
problems.append("run.mcp.stdio.command is an argument array, never a shell string")
|
|
@@ -16,10 +16,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
16
16
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
17
17
|
metadata:
|
|
18
18
|
author: PassionCode.ai
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.12.0"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
|
-
extension-commit: "
|
|
22
|
+
extension-commit: "2eb91c00e852d9c31d804310d38fad91589fcbac"
|
|
23
23
|
---
|
|
24
24
|
|
|
25
25
|
# Building Fabric services
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md
CHANGED
|
@@ -12,6 +12,7 @@ contract's own verifier does.
|
|
|
12
12
|
|
|
13
13
|
- Declaring it
|
|
14
14
|
- Wiring the gate
|
|
15
|
+
- Who must present a token
|
|
15
16
|
- Approvals
|
|
16
17
|
- What a refusal looks like
|
|
17
18
|
- Keys and the cache
|
|
@@ -29,7 +30,7 @@ the declaration (`auth.delegation-declared`) and that `/mcp` refuses the descrip
|
|
|
29
30
|
```python
|
|
30
31
|
import fabric_delegation as fd, fabric_interop as fi
|
|
31
32
|
keys = fd.JwksCache(well_known["auth"]["delegation"]["jwks"]) # https, loopback http, or a file
|
|
32
|
-
gate = fd.delegation_gate(audience="example-agent", jwks=keys, issuer=well_known["auth"]["delegation"]["issuer"],
|
|
33
|
+
gate = fd.delegation_gate(audience="example-agent", jwks=keys, issuer=well_known["auth"]["delegation"]["issuer"], # required
|
|
33
34
|
effects={"example.write": "draft", "example.publish": "publish"}) # each tool's manifest effect
|
|
34
35
|
server = fi.McpToolServer("example-agent", VERSION, authorize=gate) # pass the request headers to handle()
|
|
35
36
|
# in a handler: ctx.delegation["principal"], ["caller"], ["chain"] — record the chain with the work
|
|
@@ -43,6 +44,16 @@ const verdict = await gate(params.name, req.headers);
|
|
|
43
44
|
if (!verdict.ok) return refusalToolResult(verdict);
|
|
44
45
|
```
|
|
45
46
|
|
|
47
|
+
## Who must present a token
|
|
48
|
+
|
|
49
|
+
DL-25 places each caller in a tier by its origin: **T0** a loopback caller under the owner's account that the
|
|
50
|
+
service's own local floor (the descriptor token, the Host guard) has authenticated; **T1** an enrolled machine of
|
|
51
|
+
the same owner; **T2** everyone else, and any call relayed onto loopback from off the machine (a tunnel, a proxy, the
|
|
52
|
+
hub forwarding a T2 caller). DL-26: a T0 or T1 caller is never required to present a token — pass the tier,
|
|
53
|
+
`gate(tool, headers, tier="T0")` or `server.handle(message, headers, tier="T0")`, and the call passes with no
|
|
54
|
+
delegation. A token such a caller presents is still verified and refused when invalid. A T2 caller takes the whole
|
|
55
|
+
profile. Money limits and human gates bind at every tier.
|
|
56
|
+
|
|
46
57
|
The token is read from `Fabric-Delegation` first (a forwarded call; present but empty is refused, never skipped),
|
|
47
58
|
then from `Authorization: Bearer` when the bearer is a delegation JWT (DL-9, DL-10). A tool with no declared
|
|
48
59
|
effect is refused (`unknown-state`), never guessed. The checks run in the DL-13 order; nothing about a token is
|
|
@@ -57,7 +68,8 @@ An action with an external effect may need the operator's approval (DL-17, DL-18
|
|
|
57
68
|
then the grant check where there is one, then `verify_approval` / `verifyApproval`, which consumes the approval's
|
|
58
69
|
`jti` last, then the action. Without an approval, answer `approval-required` with
|
|
59
70
|
`action_digest(action)` / `actionDigest(action)` — the SHA-256 of the action's RFC 8785 form (`jcs`) — so the caller
|
|
60
|
-
can ask the hub.
|
|
71
|
+
can ask the hub. When the meter denies a grant check, answer `refusal_from_denial(denial)` /
|
|
72
|
+
`refusalFromDenial(denial)`: the meter's code unchanged, its window and level in `error.data` (DL-21 step 6). Keep consumed ids in `FileJtiStore` (0600, survives a restart); `MemoryJtiStore` is for tests
|
|
61
73
|
and `UNSAFE_NO_JTI_STORE` exists only by name. No store at all, or a store file that cannot be read, is
|
|
62
74
|
`unknown-state` — never a pass.
|
|
63
75
|
|
|
@@ -67,7 +79,9 @@ DL-19: an MCP tool answers `refusal_tool_result(r)` / `refusalToolResult(r)` —
|
|
|
67
79
|
`structuredContent: {error: {code, message, data: {detail}}}`; an HTTP route answers `refusal_http(r)` /
|
|
68
80
|
`refusalHttp(r)` with the code's status (401 for `unauthenticated`, `token-expired`, `principal-unproven`, with
|
|
69
81
|
`WWW-Authenticate: Bearer error="invalid_token"`; 503 for `issuer-unavailable` and `unknown-state`; 403 for the
|
|
70
|
-
rest).
|
|
82
|
+
rest). A published key the service cannot use — of small order, for another use, or published twice — is the
|
|
83
|
+
issuer's fault, `issuer-unavailable` (DEC-0045); a gate without a trusted issuer refuses everything as
|
|
84
|
+
`no-trusted-issuer`. `detail` is a short machine word; the token, the approval and the keys never appear in a result or a log.
|
|
71
85
|
|
|
72
86
|
## Keys and the cache
|
|
73
87
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `2eb91c00e852d9c31d804310d38fad91589fcbac`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
|
|
5
5
|
DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
|
|
6
|
-
delegation and the operator session's lifetime DEC-0039
|
|
6
|
+
delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
|
|
7
|
+
canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044; delegation sealed and trust tiers
|
|
8
|
+
DEC-0045). The contract's schemas are normative;
|
|
7
9
|
this page is the working summary. Extension key:
|
|
8
10
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
9
11
|
|
|
@@ -56,6 +58,9 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
|
|
|
56
58
|
```
|
|
57
59
|
|
|
58
60
|
- `status`: `starting | ready | degraded | stopping`. No answer means `down`.
|
|
61
|
+
- A `degraded[]` entry may carry `setup: true` (DEC-0044): the source waits for a person to finish setting it up
|
|
62
|
+
(a key not entered, a model not chosen). It is not an outage, and a watcher does not alert on it. The kits pass
|
|
63
|
+
entries through unchanged.
|
|
59
64
|
- `build` needs `commit` or `digest` (`sha256:<64 hex>`).
|
|
60
65
|
- `summary`: at most six tiles; `attention: true` counts toward the host's badge.
|
|
61
66
|
- `surfaces.events` is required; `dashboard` and `mcp` when the service has them; `usage`
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
CHANGED
|
@@ -57,6 +57,15 @@ export const REFUSAL_MESSAGES = Object.freeze({
|
|
|
57
57
|
|
|
58
58
|
const refuse = (code, detail, data) => (data ? { ok: false, code, detail, data } : { ok: false, code, detail });
|
|
59
59
|
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
60
|
+
/** DL-2's grammar, as the contract's verifier types the claims (DEC-0045). */
|
|
61
|
+
export const AGENT_ID = /^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$/;
|
|
62
|
+
export const OPAQUE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
63
|
+
const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
64
|
+
const JTI = /^[A-Za-z0-9._~-]{16,128}$/;
|
|
65
|
+
const SCOPE = /^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*$/;
|
|
66
|
+
const SPEND_WINDOWS = ['total', 'task', 'project', 'day', 'week', 'month'];
|
|
67
|
+
const isUri = (value) => typeof value === 'string' && URI.test(value);
|
|
68
|
+
const isAmount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
60
69
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
61
70
|
const isString = (value) => typeof value === 'string' && value.length > 0;
|
|
62
71
|
const isTime = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
@@ -125,8 +134,11 @@ function keyFor(jwks, kid) {
|
|
|
125
134
|
// A key published for another use or algorithm is never a candidate (RFC 7517 §4.2, §4.4).
|
|
126
135
|
if ((jwk.use !== undefined && jwk.use !== 'sig') || (jwk.alg !== undefined && jwk.alg !== 'EdDSA')) return { problem: 'key-not-for-signing' };
|
|
127
136
|
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !isString(jwk.x) || jwk.d !== undefined) return { problem: 'unusable-key' };
|
|
137
|
+
const raw = Buffer.from(jwk.x, 'base64url');
|
|
138
|
+
if (raw.length !== 32) return { problem: 'unusable-key' };
|
|
139
|
+
if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
|
|
128
140
|
try {
|
|
129
|
-
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw
|
|
141
|
+
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw };
|
|
130
142
|
} catch {
|
|
131
143
|
return { problem: 'unusable-key' };
|
|
132
144
|
}
|
|
@@ -159,7 +171,8 @@ function authentic(token, jwks, typ) {
|
|
|
159
171
|
if (header) return { problem: header };
|
|
160
172
|
const kid = compact.header.kid;
|
|
161
173
|
const found = keyFor(jwks, kid);
|
|
162
|
-
|
|
174
|
+
// Only an unknown kid is the token's fault; a published key that cannot be used is the issuer's (DL-13 row 3).
|
|
175
|
+
if ('problem' in found) return found.problem === 'unknown-key' ? { problem: found.problem } : { problem: found.problem, issuer: true };
|
|
163
176
|
if (compact.signature.length !== 64 || isSmallOrderKey(found.raw) || !crypto.verify(null, compact.signingInput, found.key, compact.signature)) return { problem: 'bad-signature' };
|
|
164
177
|
return { compact, kid };
|
|
165
178
|
}
|
|
@@ -193,19 +206,45 @@ export function effectClosure(ceiling) {
|
|
|
193
206
|
|
|
194
207
|
export const effectWithin = (effect, ceiling) => effectClosure(ceiling).has(effect);
|
|
195
208
|
|
|
209
|
+
/** The chain's depth when every actor is DL-2's (agent id `sub`, optional session id), else undefined. No `act`, no chain. */
|
|
210
|
+
function actorChainDepth(act) {
|
|
211
|
+
if (act === undefined) return undefined;
|
|
212
|
+
let depth = 0;
|
|
213
|
+
for (let node = act; node !== undefined; node = node.act) {
|
|
214
|
+
if (!isObject(node) || typeof node.sub !== 'string' || !AGENT_ID.test(node.sub)) return undefined;
|
|
215
|
+
if (node.session !== undefined && !(typeof node.session === 'string' && OPAQUE_ID.test(node.session))) return undefined;
|
|
216
|
+
depth += 1;
|
|
217
|
+
}
|
|
218
|
+
return depth;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function spendShaped(spend) {
|
|
222
|
+
if (!isObject(spend)) return false;
|
|
223
|
+
if (spend.mode === 'unlimited') return true;
|
|
224
|
+
if (spend.mode !== 'limited' || !Array.isArray(spend.remaining) || spend.remaining.length === 0 || spend.remaining.length > 6) return false;
|
|
225
|
+
if (!spend.remaining.every((entry) => isObject(entry) && SPEND_WINDOWS.includes(entry.window) && isAmount(entry.remainingUsd))) return false;
|
|
226
|
+
return spend.perCallMaxUsd === undefined || isAmount(spend.perCallMaxUsd);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** DL-13 row 5 as DEC-0045 types it: {claims, depth} or {problem}. A claim the profile does not know is ignored. */
|
|
196
230
|
function delegationShape(payload) {
|
|
197
|
-
const { iss, sub, act, aud, scope, effect, grant, iat, exp, jti } = payload;
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
231
|
+
const { iss, sub, act, aud, scope, effect, grant, spend, project, task, iat, exp, nbf, jti } = payload;
|
|
232
|
+
const depth = actorChainDepth(act);
|
|
233
|
+
if (depth === undefined) return { problem: 'malformed-chain' };
|
|
234
|
+
const typed = isUri(iss) && isUri(sub) && typeof aud === 'string' && AGENT_ID.test(aud)
|
|
235
|
+
&& typeof scope === 'string' && scope.length <= 4096 && SCOPE.test(scope)
|
|
236
|
+
&& Array.isArray(effect) && effect.length > 0 && new Set(effect).size === effect.length && effect.every((e) => EFFECT_CLASSES.includes(e))
|
|
237
|
+
&& Array.isArray(grant) && grant.length === 1 && typeof grant[0] === 'string' && grant[0].length >= 1 && grant[0].length <= 256
|
|
238
|
+
&& (spend === undefined || spendShaped(spend)) && (project === undefined || isUri(project))
|
|
239
|
+
&& (task === undefined || (typeof task === 'string' && OPAQUE_ID.test(task)))
|
|
240
|
+
&& isTime(iat) && isTime(exp) && (nbf === undefined || isTime(nbf)) && typeof jti === 'string' && JTI.test(jti);
|
|
241
|
+
return typed ? { claims: payload, depth } : { problem: 'malformed-claims' };
|
|
203
242
|
}
|
|
204
243
|
|
|
205
244
|
function approvalShape(payload) {
|
|
206
245
|
const { iss, actionDigest: digest, aud, approver, method, iat, exp, jti } = payload;
|
|
207
246
|
if (!isString(iss) || !isString(digest) || !isString(aud) || !isString(approver) || !isString(jti) || !isTime(iat) || !isTime(exp)) return undefined;
|
|
208
|
-
if (method !== 'prompt' && method !== 'operator-channel'
|
|
247
|
+
if (method !== 'prompt' && method !== 'operator-channel') return undefined; // DEC-0045: `device` is removed
|
|
209
248
|
return payload;
|
|
210
249
|
}
|
|
211
250
|
|
|
@@ -214,20 +253,20 @@ function approvalShape(payload) {
|
|
|
214
253
|
* the epoch. Answers {ok: true, claims, principal, caller, chain, kid} or {ok: false, code, detail}.
|
|
215
254
|
*/
|
|
216
255
|
export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly } = {}) {
|
|
256
|
+
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
217
257
|
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
218
258
|
const checked = authentic(token, jwks, DELEGATION_TYP);
|
|
219
259
|
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
|
|
260
|
+
const shaped = delegationShape(checked.compact.payload);
|
|
261
|
+
if ('problem' in shaped) return refuse('unauthenticated', shaped.problem);
|
|
262
|
+
const { claims, depth } = shaped;
|
|
263
|
+
if (claims.iss !== issuer) return refuse('unauthenticated', 'wrong-issuer');
|
|
223
264
|
if (claims.exp <= claims.iat || claims.exp - claims.iat > MAX_DELEGATION_LIFETIME_SECONDS) return refuse('unauthenticated', 'lifetime-above-profile');
|
|
224
265
|
if (claims.iat > now + LEEWAY_SECONDS) return refuse('unauthenticated', 'issued-in-future');
|
|
225
266
|
const { nbf } = checked.compact.payload;
|
|
226
|
-
if (nbf !== undefined &&
|
|
267
|
+
if (nbf !== undefined && nbf > now + LEEWAY_SECONDS) return refuse('unauthenticated', 'not-yet-valid');
|
|
227
268
|
if (now > claims.exp + LEEWAY_SECONDS) return refuse('token-expired', 'expired');
|
|
228
269
|
if (claims.aud !== audience) return refuse('wrong-audience', 'audience-mismatch');
|
|
229
|
-
const depth = chainDepth(claims.act);
|
|
230
|
-
if (!Number.isFinite(depth)) return refuse('unauthenticated', 'malformed-chain');
|
|
231
270
|
if (depth > MAX_CHAIN_DEPTH) return refuse('chain-too-deep', `depth-${depth}`);
|
|
232
271
|
if (!claims.scope.split(' ').includes(capability)) return refuse('scope-not-granted', 'capability-not-in-scope');
|
|
233
272
|
if (readOnly && effect !== 'none') return refuse('effect-above-ceiling', 'read-only-door');
|
|
@@ -239,12 +278,13 @@ export function verifyDelegation(token, { audience, jwks, now, capability, effec
|
|
|
239
278
|
export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore } = {}) {
|
|
240
279
|
if (approval === undefined || approval === null || approval === '') return refuse('approval-required', 'no-approval', { actionDigest: expected });
|
|
241
280
|
if (!isObject(jtiStore) || typeof jtiStore.consume !== 'function') return refuse('unknown-state', 'no-jti-store');
|
|
281
|
+
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
242
282
|
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
243
283
|
const checked = authentic(approval, jwks, APPROVAL_TYP);
|
|
244
284
|
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'approval-invalid', checked.problem);
|
|
245
285
|
const claims = approvalShape(checked.compact.payload);
|
|
246
286
|
if (!claims) return refuse('approval-invalid', 'malformed-claims');
|
|
247
|
-
if (
|
|
287
|
+
if (claims.iss !== issuer) return refuse('approval-invalid', 'wrong-issuer');
|
|
248
288
|
if (claims.exp <= claims.iat || claims.exp - claims.iat > MAX_APPROVAL_LIFETIME_SECONDS) return refuse('approval-invalid', 'lifetime-above-profile');
|
|
249
289
|
if (claims.iat > now + LEEWAY_SECONDS) return refuse('approval-invalid', 'issued-in-future');
|
|
250
290
|
if (now > claims.exp + LEEWAY_SECONDS) return refuse('approval-invalid', 'expired');
|
|
@@ -399,15 +439,40 @@ const kidOf = (token) => { const parsed = parseCompact(token); return 'problem'
|
|
|
399
439
|
* A check for one tools/call: `await gate(tool, headers)` answers the accepted delegation or a DL-19 refusal.
|
|
400
440
|
* `effects` maps each tool to its declared effect class; a tool with none is refused rather than guessed.
|
|
401
441
|
*/
|
|
442
|
+
export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* `await gate(tool, headers, tier = 'T2')`. DL-25, DL-26: T0 is a loopback caller under the owner's account that the
|
|
446
|
+
* service's own local floor authenticated, T1 an enrolled machine of the same owner, T2 everything else (and any call
|
|
447
|
+
* relayed from off the machine). A T0 or T1 caller is never required to present a token: with none, the call passes
|
|
448
|
+
* with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
|
|
449
|
+
*/
|
|
402
450
|
export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000 }) {
|
|
403
|
-
|
|
451
|
+
if (!isString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
|
|
452
|
+
return async (tool, headers, tier = 'T2') => {
|
|
404
453
|
const effect = effects?.[tool];
|
|
405
454
|
if (!EFFECT_CLASSES.includes(effect)) return refuse('unknown-state', 'no-declared-effect');
|
|
406
455
|
const carried = readDelegationToken(headers);
|
|
407
|
-
if (!carried)
|
|
456
|
+
if (!carried) {
|
|
457
|
+
if (tier === 'T0' || tier === 'T1') return { ok: true, tier, principal: null, caller: null, chain: [], claims: null, kid: null };
|
|
458
|
+
return refuse('unauthenticated', 'no-token');
|
|
459
|
+
}
|
|
408
460
|
const keys = jwks instanceof JwksCache ? await jwks.get(kidOf(carried.token)) : jwks;
|
|
409
|
-
|
|
461
|
+
const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer });
|
|
462
|
+
if (verdict.ok) verdict.tier = TIERS.includes(tier) ? tier : 'T2';
|
|
463
|
+
return verdict;
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** DL-21 step 6 (DEC-0045): the refusal a callee answers after the meter denied a grant check, the meter's code unchanged. */
|
|
468
|
+
export function refusalFromDenial(denial) {
|
|
469
|
+
const data = {
|
|
470
|
+
...(denial.window === undefined ? {} : { window: denial.window }),
|
|
471
|
+
...(denial.level === null || denial.level === undefined ? {} : { level: denial.level }),
|
|
472
|
+
...(denial.approvalRequest === undefined ? {} : { approvalRequest: denial.approvalRequest }),
|
|
410
473
|
};
|
|
474
|
+
const message = `the meter denied the spend: ${denial.reason ?? ''}`.slice(0, 500);
|
|
475
|
+
return { error: { code: denial.code, message, ...(Object.keys(data).length > 0 ? { data } : {}) } };
|
|
411
476
|
}
|
|
412
477
|
|
|
413
478
|
/** The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key. */
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py
CHANGED
|
@@ -77,9 +77,36 @@ REFUSAL_MESSAGES = {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
_B64URL = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
80
|
+
# DL-2's grammar, as the contract's verifier types the claims (DEC-0045): an agent id, an opaque session or task id,
|
|
81
|
+
# a URI, a jti and a scope.
|
|
82
|
+
AGENT_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$")
|
|
83
|
+
OPAQUE_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
84
|
+
_URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:\S+$")
|
|
85
|
+
_JTI = re.compile(r"^[A-Za-z0-9._~-]{16,128}$")
|
|
86
|
+
_SCOPE = re.compile(r"^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*$")
|
|
87
|
+
_SPEND_WINDOWS = ("total", "task", "project", "day", "week", "month")
|
|
88
|
+
# The small-order points of edwards25519 and their non-canonical encodings, sign bit cleared (the contract's
|
|
89
|
+
# src/ed25519.ts; libsodium's has_small_order list).
|
|
90
|
+
SMALL_ORDER_ENCODINGS = (
|
|
91
|
+
"0000000000000000000000000000000000000000000000000000000000000000", "0100000000000000000000000000000000000000000000000000000000000000",
|
|
92
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
|
|
93
|
+
"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
|
|
94
|
+
"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
|
|
95
|
+
)
|
|
80
96
|
_MISSING = object()
|
|
81
97
|
|
|
82
98
|
|
|
99
|
+
def is_small_order_key(raw: bytes) -> bool:
|
|
100
|
+
"""Whether a 32-byte Ed25519 public key is a point of small order, in any encoding (CT-29)."""
|
|
101
|
+
if len(raw) != 32:
|
|
102
|
+
return True
|
|
103
|
+
return (raw[:31] + bytes([raw[31] & 0x7F])).hex() in SMALL_ORDER_ENCODINGS
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _full(pattern: "re.Pattern[str]", value: Any) -> bool:
|
|
107
|
+
return isinstance(value, str) and pattern.fullmatch(value) is not None
|
|
108
|
+
|
|
109
|
+
|
|
83
110
|
def _refuse(code: str, detail: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
84
111
|
out: Dict[str, Any] = {"ok": False, "code": code, "detail": detail}
|
|
85
112
|
if data:
|
|
@@ -280,6 +307,8 @@ def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
|
|
|
280
307
|
return {"problem": "unusable-key"}
|
|
281
308
|
if len(raw) != 32:
|
|
282
309
|
return {"problem": "unusable-key"}
|
|
310
|
+
if is_small_order_key(raw):
|
|
311
|
+
return {"problem": "small-order-key"} # refused before any verification (DEC-0045, CT-29)
|
|
283
312
|
return {"key": raw}
|
|
284
313
|
|
|
285
314
|
|
|
@@ -298,7 +327,8 @@ def _authentic(token: Any, jwks: Dict[str, Any], typ: str) -> Dict[str, Any]:
|
|
|
298
327
|
kid = compact["header"]["kid"]
|
|
299
328
|
found = _key_for(jwks, kid)
|
|
300
329
|
if "problem" in found:
|
|
301
|
-
|
|
330
|
+
# Only an unknown kid is the token's fault; a published key that cannot be used is the issuer's (DL-13 row 3).
|
|
331
|
+
return {"problem": found["problem"], "issuer": found["problem"] != "unknown-key"}
|
|
302
332
|
if len(compact["signature"]) != 64 or not ed25519_verify(found["key"], compact["signing_input"], compact["signature"]):
|
|
303
333
|
return {"problem": "bad-signature"}
|
|
304
334
|
return {"compact": compact, "kid": kid}
|
|
@@ -341,17 +371,57 @@ def effect_within(effect: str, ceiling: List[str]) -> bool:
|
|
|
341
371
|
return effect in effect_closure(ceiling)
|
|
342
372
|
|
|
343
373
|
|
|
344
|
-
def
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if
|
|
374
|
+
def _actor_chain_depth(act: Any) -> Optional[int]:
|
|
375
|
+
"""The chain's depth when every actor is DL-2's (an agent id `sub`, an optional session id), else None. A token
|
|
376
|
+
with no `act` has no caller, so it is no chain either."""
|
|
377
|
+
if act is _MISSING:
|
|
378
|
+
return None
|
|
379
|
+
depth = 0
|
|
380
|
+
node = act
|
|
381
|
+
while node is not _MISSING:
|
|
382
|
+
if not isinstance(node, dict) or not _full(AGENT_ID, node.get("sub")):
|
|
383
|
+
return None
|
|
384
|
+
if "session" in node and not _full(OPAQUE_ID, node["session"]):
|
|
385
|
+
return None
|
|
386
|
+
depth += 1
|
|
387
|
+
node = node.get("act", _MISSING)
|
|
388
|
+
return depth
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _is_amount(value: Any) -> bool:
|
|
392
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _spend_shaped(spend: Any) -> bool:
|
|
396
|
+
if not isinstance(spend, dict):
|
|
348
397
|
return False
|
|
349
|
-
|
|
350
|
-
|
|
398
|
+
if spend.get("mode") == "unlimited":
|
|
399
|
+
return True
|
|
400
|
+
remaining = spend.get("remaining")
|
|
401
|
+
if spend.get("mode") != "limited" or not isinstance(remaining, list) or not 1 <= len(remaining) <= 6:
|
|
351
402
|
return False
|
|
352
|
-
if not isinstance(
|
|
403
|
+
if not all(isinstance(e, dict) and e.get("window") in _SPEND_WINDOWS and _is_amount(e.get("remainingUsd")) for e in remaining):
|
|
353
404
|
return False
|
|
354
|
-
return
|
|
405
|
+
return "perCallMaxUsd" not in spend or _is_amount(spend["perCallMaxUsd"])
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _delegation_shape(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[int]]:
|
|
409
|
+
"""DL-13 row 5 as DEC-0045 types it: (problem, depth). A claim the profile does not know is ignored."""
|
|
410
|
+
depth = _actor_chain_depth(payload.get("act", _MISSING))
|
|
411
|
+
if depth is None:
|
|
412
|
+
return "malformed-chain", None
|
|
413
|
+
effect, grant = payload.get("effect"), payload.get("grant")
|
|
414
|
+
typed = (_full(_URI, payload.get("iss")) and _full(_URI, payload.get("sub")) and _full(AGENT_ID, payload.get("aud"))
|
|
415
|
+
and isinstance(payload.get("scope"), str) and len(payload["scope"]) <= 4096 and _full(_SCOPE, payload["scope"])
|
|
416
|
+
and isinstance(effect, list) and len(effect) > 0 and all(isinstance(e, str) and e in EFFECT_CLASSES for e in effect)
|
|
417
|
+
and len(set(effect)) == len(effect)
|
|
418
|
+
and isinstance(grant, list) and len(grant) == 1 and isinstance(grant[0], str) and 1 <= _utf16_length(grant[0]) <= 256
|
|
419
|
+
and ("spend" not in payload or _spend_shaped(payload["spend"]))
|
|
420
|
+
and ("project" not in payload or _full(_URI, payload["project"]))
|
|
421
|
+
and ("task" not in payload or _full(OPAQUE_ID, payload["task"]))
|
|
422
|
+
and _is_time(payload.get("iat")) and _is_time(payload.get("exp"))
|
|
423
|
+
and ("nbf" not in payload or _is_time(payload["nbf"])) and _full(_JTI, payload.get("jti")))
|
|
424
|
+
return (None, depth) if typed else ("malformed-claims", None)
|
|
355
425
|
|
|
356
426
|
|
|
357
427
|
def _approval_shape(payload: Dict[str, Any]) -> bool:
|
|
@@ -359,37 +429,37 @@ def _approval_shape(payload: Dict[str, Any]) -> bool:
|
|
|
359
429
|
return False
|
|
360
430
|
if not _is_time(payload.get("iat")) or not _is_time(payload.get("exp")):
|
|
361
431
|
return False
|
|
362
|
-
return payload.get("method") in ("prompt", "operator-channel"
|
|
432
|
+
return payload.get("method") in ("prompt", "operator-channel") # DEC-0045: `device` is removed
|
|
363
433
|
|
|
364
434
|
|
|
365
435
|
def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
|
|
366
|
-
issuer: Optional[str]
|
|
436
|
+
issuer: Optional[str], read_only: bool = False) -> Dict[str, Any]:
|
|
367
437
|
"""DL-13, in its fixed order: nothing about a token is believed before its signature is.
|
|
368
438
|
`now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
|
|
369
|
-
{ok: False, code, detail[, data]} whose detail is a short machine word, never the token."""
|
|
439
|
+
{ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required."""
|
|
440
|
+
if not _is_string(issuer):
|
|
441
|
+
return _refuse("issuer-unavailable", "no-trusted-issuer")
|
|
370
442
|
if not _has_keys(jwks):
|
|
371
443
|
return _refuse("issuer-unavailable", "no-published-keys")
|
|
372
444
|
checked = _authentic(token, jwks, DELEGATION_TYP)
|
|
373
445
|
if "problem" in checked:
|
|
374
446
|
return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
|
|
375
447
|
claims = checked["compact"]["payload"]
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
448
|
+
problem, depth = _delegation_shape(claims)
|
|
449
|
+
if problem:
|
|
450
|
+
return _refuse("unauthenticated", problem)
|
|
451
|
+
if claims["iss"] != issuer:
|
|
379
452
|
return _refuse("unauthenticated", "wrong-issuer")
|
|
380
453
|
if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_DELEGATION_LIFETIME_SECONDS:
|
|
381
454
|
return _refuse("unauthenticated", "lifetime-above-profile")
|
|
382
455
|
if claims["iat"] > now + LEEWAY_SECONDS:
|
|
383
456
|
return _refuse("unauthenticated", "issued-in-future")
|
|
384
|
-
if "nbf" in claims and
|
|
457
|
+
if "nbf" in claims and claims["nbf"] > now + LEEWAY_SECONDS:
|
|
385
458
|
return _refuse("unauthenticated", "not-yet-valid")
|
|
386
459
|
if now > claims["exp"] + LEEWAY_SECONDS:
|
|
387
460
|
return _refuse("token-expired", "expired")
|
|
388
461
|
if claims["aud"] != audience:
|
|
389
462
|
return _refuse("wrong-audience", "audience-mismatch")
|
|
390
|
-
depth = chain_depth(claims["act"])
|
|
391
|
-
if not math.isfinite(depth):
|
|
392
|
-
return _refuse("unauthenticated", "malformed-chain")
|
|
393
463
|
if depth > MAX_CHAIN_DEPTH:
|
|
394
464
|
return _refuse("chain-too-deep", "depth-%d" % depth)
|
|
395
465
|
if capability not in claims["scope"].split(" "):
|
|
@@ -473,13 +543,15 @@ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
|
|
|
473
543
|
|
|
474
544
|
|
|
475
545
|
def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
|
|
476
|
-
jti_store: Any, issuer: Optional[str]
|
|
546
|
+
jti_store: Any, issuer: Optional[str]) -> Dict[str, Any]:
|
|
477
547
|
"""DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
|
|
478
548
|
approval-invalid; a second use is approval-reused. The jti is consumed last."""
|
|
479
549
|
if approval is None or approval == "":
|
|
480
550
|
return _refuse("approval-required", "no-approval", {"actionDigest": action_digest})
|
|
481
551
|
if jti_store is None or not callable(getattr(jti_store, "consume", None)):
|
|
482
552
|
return _refuse("unknown-state", "no-jti-store")
|
|
553
|
+
if not _is_string(issuer):
|
|
554
|
+
return _refuse("issuer-unavailable", "no-trusted-issuer")
|
|
483
555
|
if not _has_keys(jwks):
|
|
484
556
|
return _refuse("issuer-unavailable", "no-published-keys")
|
|
485
557
|
checked = _authentic(approval, jwks, APPROVAL_TYP)
|
|
@@ -488,7 +560,7 @@ def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: f
|
|
|
488
560
|
claims = checked["compact"]["payload"]
|
|
489
561
|
if not _approval_shape(claims):
|
|
490
562
|
return _refuse("approval-invalid", "malformed-claims")
|
|
491
|
-
if
|
|
563
|
+
if claims["iss"] != issuer:
|
|
492
564
|
return _refuse("approval-invalid", "wrong-issuer")
|
|
493
565
|
if claims["exp"] <= claims["iat"] or claims["exp"] - claims["iat"] > MAX_APPROVAL_LIFETIME_SECONDS:
|
|
494
566
|
return _refuse("approval-invalid", "lifetime-above-profile")
|
|
@@ -654,8 +726,12 @@ _OPENER = urllib.request.build_opener(_NoRedirect)
|
|
|
654
726
|
def _fetch(source: str) -> Any:
|
|
655
727
|
if source.startswith(("https://", "http://")):
|
|
656
728
|
request = urllib.request.Request(source, headers={"Accept": "application/json"})
|
|
657
|
-
|
|
658
|
-
|
|
729
|
+
try:
|
|
730
|
+
with _OPENER.open(request, timeout=5) as response: # https or loopback http only, no redirect
|
|
731
|
+
return json.loads(response.read(65537)[:65536])
|
|
732
|
+
except urllib.error.HTTPError as exc:
|
|
733
|
+
exc.close()
|
|
734
|
+
raise
|
|
659
735
|
return json.loads(Path(source).expanduser().read_text())
|
|
660
736
|
|
|
661
737
|
|
|
@@ -715,26 +791,54 @@ def _kid_of(token: str) -> Optional[str]:
|
|
|
715
791
|
|
|
716
792
|
# --- the gate in front of a service's tools ------------------------------------------------------------------
|
|
717
793
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
794
|
+
TIERS = ("T0", "T1", "T2")
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
|
|
798
|
+
read_only: bool = False, clock: Callable[[], float] = time.time) -> Callable[..., Dict[str, Any]]:
|
|
799
|
+
"""A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
|
|
800
|
+
refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
|
|
801
|
+
refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
|
|
723
802
|
|
|
724
|
-
|
|
803
|
+
DL-25, DL-26: `tier` is the caller's origin. T0 is a loopback caller under the owner's account that the service's
|
|
804
|
+
own local floor has authenticated; T1 an enrolled machine of the same owner; T2 everything else, and any call
|
|
805
|
+
relayed from off the machine. A T0 or T1 caller is never required to present a token: with none, the call
|
|
806
|
+
passes with no delegation ({ok: True, tier, principal: None, ...}). A present token is verified at every tier
|
|
807
|
+
and refused when invalid. Unknown tiers count as T2."""
|
|
808
|
+
if not _is_string(issuer):
|
|
809
|
+
raise ValueError("the gate needs the issuer it trusts (auth.delegation.issuer)")
|
|
810
|
+
|
|
811
|
+
def gate(tool: str, headers: Any, tier: str = "T2") -> Dict[str, Any]:
|
|
725
812
|
effect = effects.get(tool)
|
|
726
813
|
if effect not in EFFECT_CLASSES:
|
|
727
814
|
return _refuse("unknown-state", "no-declared-effect")
|
|
728
815
|
carried = read_delegation_token(headers)
|
|
729
816
|
if carried is None:
|
|
817
|
+
if tier in ("T0", "T1"):
|
|
818
|
+
return {"ok": True, "tier": tier, "principal": None, "caller": None, "chain": [], "claims": None, "kid": None}
|
|
730
819
|
return _refuse("unauthenticated", "no-token")
|
|
731
820
|
keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
|
|
732
|
-
|
|
733
|
-
|
|
821
|
+
verdict = verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
|
|
822
|
+
effect=effect, issuer=issuer, read_only=read_only)
|
|
823
|
+
if verdict["ok"]:
|
|
824
|
+
verdict["tier"] = tier if tier in TIERS else "T2"
|
|
825
|
+
return verdict
|
|
734
826
|
|
|
735
827
|
return gate
|
|
736
828
|
|
|
737
829
|
|
|
830
|
+
def refusal_from_denial(denial: Dict[str, Any]) -> Dict[str, Any]:
|
|
831
|
+
"""DL-21 step 6 (DEC-0045): the refusal a callee answers after the meter denied a grant check. The meter's code
|
|
832
|
+
goes unchanged into error.code; its window, level (when set) and approvalRequest into error.data."""
|
|
833
|
+
data = {k: denial[k] for k in ("window", "approvalRequest") if denial.get(k) is not None}
|
|
834
|
+
if denial.get("level") is not None:
|
|
835
|
+
data["level"] = denial["level"]
|
|
836
|
+
error: Dict[str, Any] = {"code": denial["code"], "message": ("the meter denied the spend: %s" % denial.get("reason", ""))[:500]}
|
|
837
|
+
if data:
|
|
838
|
+
error["data"] = data
|
|
839
|
+
return {"error": error}
|
|
840
|
+
|
|
841
|
+
|
|
738
842
|
def refusal_error(refusal: Dict[str, Any]) -> Dict[str, Any]:
|
|
739
843
|
"""The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key."""
|
|
740
844
|
data = {"detail": refusal["detail"]}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -479,7 +479,7 @@ class McpToolServer:
|
|
|
479
479
|
on_job_started: Optional[Callable[[str, CallContext], None]] = None,
|
|
480
480
|
on_input: Optional[OnInput] = None,
|
|
481
481
|
authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None):
|
|
482
|
-
# authorize(tool, headers) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
|
|
482
|
+
# authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
|
|
483
483
|
# a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
|
|
484
484
|
# gated by the job's own capability, and answered only for the principal and caller that started it.
|
|
485
485
|
self._authorize = authorize
|
|
@@ -517,10 +517,12 @@ class McpToolServer:
|
|
|
517
517
|
def _error(rid: Any, code: int, message: str) -> Dict[str, Any]:
|
|
518
518
|
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
|
|
519
519
|
|
|
520
|
-
def handle(self, message: Any, headers: Any = None) -> Optional[Dict[str, Any]]:
|
|
520
|
+
def handle(self, message: Any, headers: Any = None, tier: str = "T2") -> Optional[Dict[str, Any]]:
|
|
521
521
|
"""One JSON-RPC message. Pass the HTTP request headers so the 2026-07-28 standard headers
|
|
522
522
|
are checked (`mcp_header_problem`); a mismatch answers HEADER_MISMATCH even for a
|
|
523
|
-
notification, so the transport can return HTTP 400.
|
|
523
|
+
notification, so the transport can return HTTP 400. `tier` is the caller's origin for the
|
|
524
|
+
authorize gate (DL-25: T0 local and authenticated by the service's floor, T1 an enrolled
|
|
525
|
+
machine of the owner, T2 anything else); a gate never requires a token from T0 or T1."""
|
|
524
526
|
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
|
|
525
527
|
return self._error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request.")
|
|
526
528
|
problem = mcp_header_problem(message, headers)
|
|
@@ -558,7 +560,7 @@ class McpToolServer:
|
|
|
558
560
|
private = self.jobs.private(job_id)
|
|
559
561
|
except UnknownJob:
|
|
560
562
|
return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(job_id, traceparent=span)}
|
|
561
|
-
verdict = self._authorize(private["capability"], headers)
|
|
563
|
+
verdict = self._authorize(private["capability"], headers, tier=tier)
|
|
562
564
|
owner = private.get("delegation")
|
|
563
565
|
if verdict.get("ok") and owner and (owner.get("principal"), owner.get("caller")) != (verdict.get("principal"), verdict.get("caller")):
|
|
564
566
|
verdict = {"ok": False, "code": "scope-not-granted", "detail": "job-of-another-caller"}
|
|
@@ -571,7 +573,7 @@ class McpToolServer:
|
|
|
571
573
|
return self._error(rid, -32602, "Unknown tool: %s." % name)
|
|
572
574
|
delegation = None
|
|
573
575
|
if self._authorize is not None:
|
|
574
|
-
verdict = self._authorize(name, headers)
|
|
576
|
+
verdict = self._authorize(name, headers, tier=tier)
|
|
575
577
|
if not verdict.get("ok"):
|
|
576
578
|
import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
|
|
577
579
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.12.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "2eb91c00e852d9c31d804310d38fad91589fcbac"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -119,7 +119,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
119
119
|
--schema-base <immutable-base-uri>
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
Pin exactly contract `0.1.0` at commit `
|
|
122
|
+
Pin exactly contract `0.1.0` at commit `2eb91c00e852d9c31d804310d38fad91589fcbac` and
|
|
123
123
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
124
124
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
125
125
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|