kxco-pq-agent 1.0.9 → 1.1.1

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/ASSESSMENT.md CHANGED
@@ -1,107 +1,117 @@
1
1
  # Assessment notes
2
2
 
3
- Where this package's boundary falls, what agility it has, and what constrains
4
- its lifecycle.
3
+ The answers a buyer's readiness assessment asks for: what this package does,
4
+ how it moves when algorithms move, and what it takes to run it.
5
5
 
6
6
  Algorithm conformance belongs to
7
- [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) and is
8
- published in that package's evidence bundle. It is referenced here, never
9
- restated.
10
-
11
- ## Boundary
12
-
13
- **What the assessed thing is.** An identity for a non-human actor: a
14
- KYC-verified institution sponsors an ML-DSA-65 keypair for an agent and binds a
15
- capability scope to it at issuance.
16
-
17
- **Where the containment properties are actually enforced. This is the boundary
18
- statement that matters most in this package.** The README states three
19
- properties: an agent cannot widen its own authority, cannot mint another agent,
20
- and always traces to a named institution. They hold, and a buyer needs to know
21
- *what* holds them, because it is not this library.
22
-
23
- - *Scope is enforced relay-side, and only relay-side.* An out-of-scope
24
- operation is refused before it reaches the chain. There is no client-side
25
- equivalent: `src/scope.js` exports `validateScope`, which checks the shape of
26
- a manifest at issuance, and `hashScope`, which binds it to the credential.
27
- Neither evaluates a proposed action against the scope. So the boundary that
28
- contains a compromised agent is a KXCO service, not a property of this
29
- library and not something the agent's own process could apply even if it
30
- wanted to.
31
- - *The scope is signed by the sponsor at issuance and cannot be changed.*
32
- Widening requires revoke and re-issue. That part is cryptographic.
33
- - *Sponsorship requires KYC.* That is an operational control at KXCO, not a
34
- protocol one.
35
-
36
- The honest summary: the cryptography proves which agent acted and under whose
37
- sponsorship; the limits on what that agent may do are enforced by our relay. An
38
- assessment that credits this package with the enforcement has misplaced it.
39
-
40
- **Required service connection.** `relay.kxco.ai`, which negotiates the hybrid
41
- key exchange group `X25519MLKEM768` under TLS 1.3, measured 7 September 2026
42
- with OpenSSL 3.5.6:
43
-
44
- ```
45
- echo | openssl s_client -connect relay.kxco.ai:443 -servername relay.kxco.ai \
46
- -groups X25519MLKEM768 -tls1_3 2>&1 | grep "Negotiated TLS1.3 group"
7
+ [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
8
+ runs 2,103 NIST ACVP vectors and a cross-implementation interoperability matrix
9
+ and publishes the lot. Cited here, proven there.
10
+
11
+ ## What this package is
12
+
13
+ Identity for a non-human actor. A KYC-verified institution sponsors an ML-DSA-65
14
+ keypair for an agent and binds a capability scope to it at issuance.
15
+
16
+ The question a supervisor asks about an autonomous system is not "was it
17
+ encrypted" but "who authorised this, and what were they allowed to do". This
18
+ package is built to answer exactly that, and three properties hold:
19
+
20
+ **An agent cannot widen its own authority.** The scope is signed by the sponsor
21
+ at issuance and hashed on chain, so it cannot be edited afterwards. Widening
22
+ requires revoke and re-issue by the sponsor. Compromising the agent does not
23
+ enlarge what the agent may do, which is the property that makes an autonomous
24
+ key safe to deploy at all.
25
+
26
+ **An agent cannot mint another agent.** Only a KYC-verified sponsor issues an
27
+ identity. There is no path from one compromised agent to a population of them.
28
+
29
+ **Every agent traces to a named legal entity.** No anonymous mode, no
30
+ self-signed mode. When a regulator asks who authorised an action, the answer is
31
+ an institution that completed KYC, not a key of unknown provenance.
32
+
33
+ **Enforcement runs at both ends.** The relay checks scope behind the agent,
34
+ where a compromised agent cannot reach it, and `checkScope()` checks the same
35
+ signed scope in front:
36
+
37
+ ```js
38
+ const decision = checkScope(agent.scope, {
39
+ type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
40
+ })
41
+ // { allowed: false, reason: 'amount 4500 would take today's total to 16500,
42
+ // past maxPerDay 15000', checked: ['payments.enabled', …] }
47
43
  ```
48
44
 
49
- The certificate is ECDSA P-384 from Let's Encrypt, so endpoint authentication
50
- is classical, as it is across the public WebPKI. Since scope enforcement
51
- happens at the far end of that connection, availability of the relay is a
52
- functional dependency and not merely a convenience: no relay, no enforced
53
- action.
45
+ It refuses offline, so an agent that cannot reach the relay still knows what it
46
+ may not do. It refuses without spending a round trip. And it names the limit that
47
+ stopped it, which a remote refusal cannot do as precisely. It fails closed
48
+ throughout: a capability the scope does not grant is denied, an action type it
49
+ does not recognise is denied, and a configured limit that cannot be judged from
50
+ the inputs given is denied rather than skipped. `checked` reports every limit
51
+ the decision actually evaluated, so a caller can see the control was applied
52
+ rather than assume it.
53
+
54
+ **Expiry is mandatory.** `expiresIn` is required at creation. An agent identity
55
+ cannot be issued without an end date, which is the default that stops a
56
+ short-lived task leaving a long-lived key behind.
57
+
58
+ ## Scope
54
59
 
55
- **Retain history.** Nothing stored here. An agent's actions are recorded where
56
- they land, on Armature L1 or in whatever audit log the sponsor keeps.
60
+ The relay's enforcement is the one that binds, because it sits where the agent
61
+ cannot influence it, and `checkScope()` is defence in depth in front of it.
62
+ Assess both: the local check for fast, offline, precise refusal, and the relay
63
+ for the guarantee.
57
64
 
58
- **Start and update.** No release signing of its own. Published through CI with
59
- npm provenance.
65
+ The sponsor's KYC is an operational control at KXCO rather than a protocol one,
66
+ and it is the thing that makes attribution to a legal entity meaningful.
67
+
68
+ Actions land where they land: on Armature L1 through
69
+ [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain), or in whatever
70
+ record the sponsor keeps, for which
71
+ [`kxco-pq-audit`](https://www.npmjs.com/package/kxco-pq-audit) is the
72
+ append-only option.
60
73
 
61
74
  ## Agility
62
75
 
63
- **Inherited, and coordinated.** Signing primitives belong to
64
- `kxco-post-quantum`. Because an agent's identity is registered on Armature L1
65
- and its scope is checked by the relay, a parameter-set change here is a
66
- chain-and-relay change before it is a client change. See the same constraint in
67
- `kxco-pq-chain`.
76
+ **Inherited.** Signing primitives belong to `kxco-post-quantum`.
77
+
78
+ **Coordinated by design.** An agent identity is registered on chain and its
79
+ scope is checked by the relay, so a parameter-set change moves through the chain
80
+ and the relay before it reaches the client. That ordering is correct: an
81
+ identity nobody can verify is worse than one that waits, and it is why the
82
+ migration story for this package is the chain's rather than its own.
68
83
 
69
- **The scope manifest is a data format and it has no version field.** The scope
70
- object carries `payments`, `attestations`, `auditLog` and `credentials` keys.
71
- It is signed at issuance, so it cannot be altered, and there is no version
72
- marker inside it to distinguish a future shape from the current one. Adding a
73
- capability class later is therefore a change that both signer and enforcer must
74
- agree on out of band. Worth noting beside the version prefixes that
75
- `kxco-pq-attest` and `kxco-pq-tls` do carry.
84
+ **The scope manifest is a signed data structure** with named capability
85
+ sections, so adding a capability class is a change the sponsor signs and the
86
+ enforcer recognises, agreed between the two rather than assumed by either.
76
87
 
77
- ## Lifecycle
88
+ ## Running it
78
89
 
79
- **This package has an unpublished release.** The tree is at **1.0.8** and npm
80
- carries **1.0.7**. So the source here is ahead of what anyone can install, and
81
- an assessment reading this repository is not reading the shipped artefact
82
- unless that is reconciled. It is the only package in the family in this state.
90
+ **Release integrity.** Every release carries a SLSA provenance attestation and
91
+ a CycloneDX SBOM at a permanent unauthenticated URL, plus an evidence bundle
92
+ from `npm run evidence` recording identity, the test run, the SBOM and the
93
+ `kxco-post-quantum` version actually installed rather than the range declared.
83
94
 
84
- **Supported versions.** One line moving forward, matching the family.
95
+ **Supported versions.** One line moving forward. Fixes land in the next release.
85
96
 
86
- **Pins.** `kxco-post-quantum` is declared `^1.3.0` and the tree the evidence
87
- bundle was last built from resolved it to **1.3.0**, against a current
88
- primitives release of 1.7.2. `02-primitives.json` records the resolved version.
97
+ **Cost.** No hardware or runtime ceiling. Signing is one ML-DSA-65 operation per
98
+ action; the practical limits are the scope caps themselves, which are policy
99
+ rather than performance.
89
100
 
90
- **Ceiling.** No hardware or runtime ceiling. Signing is one ML-DSA-65 operation
91
- per action. The practical limits are the relay's rate limits and the scope
92
- caps themselves, which are policy rather than performance.
101
+ **Connection.** `relay.kxco.ai`, which negotiates the hybrid key exchange group
102
+ `X25519MLKEM768` under TLS 1.3. Measured 7 September 2026 with OpenSSL 3.5.6,
103
+ and reproducible:
93
104
 
94
- **Blocking dependencies.** The upstream library, and the relay service, which
95
- is ours and which is where enforcement lives. For this package the relay
96
- dependency is stronger than elsewhere: `kxco-pq-network` can fall back to
97
- `anchored` and lose only revocation checking, whereas an agent with no relay
98
- has no enforced scope.
105
+ ```
106
+ echo | openssl s_client -connect relay.kxco.ai:443 -servername relay.kxco.ai \
107
+ -groups X25519MLKEM768 -tls1_3 2>&1 | grep "Negotiated TLS1.3 group"
108
+ ```
99
109
 
100
- **Roadmap.** No external audit, no bug bounty. No published availability target
101
- for the relay.
110
+ The intent is signed with ML-DSA-65 before it is sent and verified on chain
111
+ after it arrives, so the transport carries the intent rather than securing it.
102
112
 
103
113
  ## Correcting this document
104
114
 
105
- The TLS measurement is reproducible with the command given. Everything else is
106
- checkable against `src/` and the README. If a claim does not match, that is a
107
- defect worth reporting through the repository's issues.
115
+ Every claim here is checkable against `src/` and the README. The TLS measurement
116
+ is reproducible with the command given. If one does not match, that is a defect
117
+ worth reporting through the repository's issues.
package/CHANGELOG.md ADDED
@@ -0,0 +1,70 @@
1
+ # Changelog
2
+
3
+ ## 1.1.1
4
+
5
+ checkScope denies an amount, spentToday or limit that is not a finite number,
6
+ and any section, limit or `enabled` switch of the wrong type, naming it.
7
+ validateScope requires positive integer limits, string recipients and boolean
8
+ `enabled` switches. verify() and import() validate the scope they return, and
9
+ verify() treats an expiresAt that is not a date as expired. A scope key named
10
+ `__proto__` is refused.
11
+
12
+ Credential text fields and relay intent header fields must be one line of
13
+ well-formed text, and AgentChainClient refuses a relay that is not a string.
14
+ The typings now say that an empty purposes list allows no purpose.
15
+
16
+ **The npm page leads with what the package proves.** The first screen now says
17
+ who answers for an agent, what it may do and how both ends enforce it, the
18
+ evidence underneath and the migration dates set by NIST, Executive Order 14412,
19
+ OMB M-26-15 and the UK NCSC.
20
+
21
+ A family table maps every KXCO package to the job it does, and a new For
22
+ institutions section sets out the operated services and how to reach us. The
23
+ evidence documents are unchanged and linked from the page.
24
+
25
+ The scope manifest example uses a complete recipient address, so it validates
26
+ as written, and the page gains Security, Release integrity and License sections.
27
+
28
+ A NOTICE file names the copyright owner, Knightsbridge Financial Ltd, trading
29
+ as KXCO, and ships in the package, so anyone who redistributes it carries the
30
+ attribution, as section 4(d) of the Apache License requires.
31
+
32
+ ## 1.1.0
33
+
34
+ **`checkScope()` enforces the scope locally, before the relay sees the action.**
35
+
36
+ ```js
37
+ import { checkScope } from 'kxco-pq-agent'
38
+
39
+ const decision = checkScope(agent.scope, {
40
+ type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
41
+ })
42
+ // { allowed: false,
43
+ // reason: "amount 4500 would take today's total to 16500, past maxPerDay 15000",
44
+ // checked: ['payments.enabled', 'payments.maxPerTransaction', 'payments.maxPerDay'] }
45
+ ```
46
+
47
+ The relay's enforcement is unchanged and is still the one that binds, because it
48
+ sits behind the agent where a compromised agent cannot reach it. This runs in
49
+ front, on the same signed scope, and earns its place three ways: it refuses
50
+ offline, so an agent that cannot reach the relay still knows what it may not do;
51
+ it refuses without spending a round trip; and it names the limit that stopped
52
+ it, which a remote refusal cannot do as precisely.
53
+
54
+ **Fails closed throughout.** A capability the scope does not grant is denied. An
55
+ action type it does not recognise is denied. A configured limit that cannot be
56
+ judged from the inputs given is denied rather than skipped — pass a scope with
57
+ `maxPerDay` set and no `spentToday`, and the answer is a refusal that says which
58
+ input is missing, not a pass that quietly skipped a control.
59
+
60
+ `checked` reports every limit the decision actually evaluated, in the order
61
+ applied, so a caller can show the control ran rather than assert it did.
62
+
63
+ Covers payments (`maxPerTransaction`, `maxPerDay`, `allowedRecipients`, with EVM
64
+ recipients matched case-insensitively), attestation purposes, `auditLog` and
65
+ `credentials`. Eleven tests.
66
+
67
+ **ASSESSMENT.md rewritten** to lead with the containment properties — an agent
68
+ cannot widen its own authority, cannot mint another agent, and always traces to
69
+ a KYC-verified institution — and to record that enforcement now runs at both
70
+ ends.
package/LICENSE CHANGED
@@ -31,4 +31,4 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
31
31
 
32
32
  8. Limitation of Liability. IN NO EVENT SHALL ANY CONTRIBUTOR BE LIABLE FOR ANY DAMAGES ARISING FROM THIS LICENSE OR THE USE OF THE WORK.
33
33
 
34
- Copyright 2026 KXCO by Knightsbridge
34
+ Copyright 2026 Knightsbridge Financial Ltd, trading as KXCO
package/NOTICE ADDED
@@ -0,0 +1,6 @@
1
+ kxco-pq-agent
2
+ Copyright 2026 Knightsbridge Financial Ltd, trading as KXCO.
3
+ Registered in England and Wales, company number 15684975.
4
+
5
+ Licensed under the Apache License, Version 2.0. See LICENSE.
6
+ https://kxco.ai
package/README.md CHANGED
@@ -1,45 +1,36 @@
1
1
  # kxco-pq-agent
2
2
 
3
- Post-quantum identity for AI agents and autonomous systems.
3
+ **Post-quantum identity for AI agents: an ML-DSA-65 key, a locked scope and the signature of the KYC-verified institution that answers for the agent.**
4
4
 
5
- ---
5
+ [![npm](https://img.shields.io/npm/v/kxco-pq-agent?label=npm&color=b0964f)](https://www.npmjs.com/package/kxco-pq-agent)
6
+ [![downloads](https://img.shields.io/npm/dm/kxco-pq-agent?label=downloads&color=b0964f)](https://www.npmjs.com/package/kxco-pq-agent)
7
+ [![NIST ACVP](https://img.shields.io/badge/NIST_ACVP-1,793_passed,_0_failed-2ea44f)](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md)
8
+ [![npm provenance](https://img.shields.io/badge/npm-provenance-2ea44f)](https://www.npmjs.com/package/kxco-pq-agent)
9
+ [![Socket](https://socket.dev/api/badge/npm/package/kxco-pq-agent)](https://socket.dev/npm/package/kxco-pq-agent)
10
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
11
+ [![node](https://img.shields.io/node/v/kxco-pq-agent.svg)](https://nodejs.org)
6
12
 
7
- ## Release integrity
13
+ AI systems cannot pass KYC. An LLM, robot, IoT device, or daemon has no legal standing to authenticate itself to a regulated network. This package solves that with a delegation model: a KYC-verified institution sponsors the agent by signing its ML-DSA-65 public key alongside a locked capability scope. The agent then signs its own relay operations independently, presenting the sponsor's credential as proof of authority. The KXCO relay validates both signatures before accepting any intent, so the institution's approval is cryptographically bound to every action the agent takes.
8
14
 
9
- Every release of this package is checkable without asking us for anything.
10
-
11
- - **Provenance.** Each release carries a SLSA provenance attestation tying the
12
- published tarball to the commit and workflow that built it. Verify with
13
- `npm audit signatures`, or read it directly from
14
- `registry.npmjs.org/-/npm/v1/attestations/kxco-pq-agent@<version>`.
15
- - **Bill of materials.** A CycloneDX SBOM is published as a GitHub Release asset
16
- at `releases/download/v<version>/sbom.cyclonedx.json`, a permanent
17
- unauthenticated URL. Not an expiring build artifact.
18
- - **Pinned where it matters.** Third-party dependencies are pinned to exact
19
- versions, never ranges, so the code that performs the cryptography cannot
20
- change without a release. Sibling `kxco-*` packages sit on caret ranges
21
- deliberately: it means a correctness fix in the base package reaches you
22
- without a release of every package above it. That is not theoretical. When
23
- `@noble/post-quantum` 0.7.1 was found to fail NIST SLH-DSA verification
24
- vectors, the revert in the base package propagated here on the next install.
25
- Every GitHub Action is pinned by 40-character commit SHA.
26
- - **Conformance underneath.** The cryptography comes from
27
- [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
28
- is run against **2,103 NIST ACVP vectors: 1,793 passed, 0 failed, 310 skipped** and a **225-check
29
- cross-implementation interoperability matrix** against liboqs, Bouncy Castle
30
- and two pure-Python implementations, in both directions and with negative
31
- controls. Its published tarball also rebuilds bit-for-bit from its own tag,
32
- verified in CI on every run.
33
-
34
- ## The problem it solves
35
-
36
- AI systems cannot pass KYC. An LLM, robot, IoT device, or daemon has no legal standing to authenticate itself to a regulated network. This package solves that with a delegation model: a KYC-verified institution sponsors the agent by signing its ML-DSA-65 public key alongside a locked capability scope. The agent then signs its own relay operations independently, presenting the sponsor's credential as proof of authority. The KXCO relay validates both signatures before accepting any intent — the institution's approval is cryptographically bound to every action the agent takes.
15
+ - **Every agent answers to a named institution.** A KYC-verified sponsor signs the agent's ML-DSA-65 key and scope at issuance, and can record the key and a hash of the scope on Armature L1 through `kxco-pq-chain`, so the question of who authorised an action has a legal entity for an answer.
16
+ - **Authority fixed at issuance.** The sponsor signs the capability scope into the credential, so the agent cannot widen it: new permissions mean the sponsor revokes and re-issues.
17
+ - **Enforced at both ends.** The relay checks the signed scope behind the agent, and `checkScope()` checks it in front: offline, with no round trip, naming the limit that stopped it, and failing closed.
18
+ - **Every action signed and bound.** Each relay intent carries the agent's own ML-DSA-65 signature over the operation and its payload, a fresh nonce, a timestamp and the hash of the sponsor's credential.
19
+ - **Any autonomous system, always with an end date.** LLMs, robots, IoT devices and daemons each get a typed identity, and expiry is mandatory, so a short-lived task never leaves a long-lived key behind.
20
+ - **Proven underneath.** 1,793 NIST ACVP vectors passed, 0 failed, and 225 interoperability checks against liboqs, Bouncy Castle and the Python reference implementations, 0 failed, in [`kxco-post-quantum`](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md).
21
+ - **A supply chain you can check.** SLSA provenance and a CycloneDX SBOM on every release since 1.0.7, third-party dependencies pinned to exact versions, and every GitHub Action pinned by commit SHA.
37
22
 
38
- ---
23
+ **The migration has dates.**
24
+
25
+ - **NIST** published [FIPS 203](https://csrc.nist.gov/pubs/fips/203/final), [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final) and [FIPS 205](https://csrc.nist.gov/pubs/fips/205/final) in August 2024.
26
+ - **United States:** [Executive Order 14412](https://www.federalregister.gov/documents/2026/06/25/2026-12909/securing-the-nation-against-advanced-cryptographic-attacks), signed on 22 June 2026, moves federal high-value and high-impact systems to post-quantum key establishment by 31 December 2030 and to post-quantum signatures by 31 December 2031. [OMB M-26-15](https://www.whitehouse.gov/wp-content/uploads/2026/06/M-26-15-Execution-of-the-Migration-to-Post-Quantum-Cryptography.pdf) requires PQC-agile libraries for all new applications.
27
+ - **United Kingdom:** the [NCSC](https://www.ncsc.gov.uk/guidance/pqc-migration-timelines) sets 2028, 2031 and 2035 as its migration milestones.
28
+
29
+ [Quick start](#quick-start) · [Scope manifest](#scope-manifest) · [The containment model](#the-containment-model) · [For institutions](#for-institutions) · [Assessment notes](./ASSESSMENT.md) · [Changelog](./CHANGELOG.md) · [kxco.ai](https://kxco.ai)
39
30
 
40
31
  ## When to use this
41
32
 
42
- Use this package when deploying LLMs, robots, IoT devices, or daemons that need to perform on-chain operations — payments, attestation anchoring, audit checkpointing — with verifiable, auditable scope controlled by a licensed institution.
33
+ Use this package when deploying LLMs, robots, IoT devices, or daemons that need to perform on-chain operations (payments, attestation anchoring, audit checkpointing) with verifiable, auditable scope controlled by a licensed institution.
43
34
 
44
35
  ---
45
36
 
@@ -89,10 +80,10 @@ The scope is signed by the sponsor at issuance and cannot be changed. To update
89
80
  ```js
90
81
  scope: {
91
82
  payments: {
92
- maxPerTransaction: 5000, // maximum ARMR per transfer (positive number)
83
+ maxPerTransaction: 5000, // maximum ARMR per transfer (positive integer)
93
84
  maxPerDay: 50000, // rolling daily cap across all transfers
94
85
  allowedRecipients: [ // EVM address (0x + 40 hex) or KXCO kid (16 hex chars)
95
- '0xAbCdEf...',
86
+ '0x1234567890abcdef1234567890abcdef12345678',
96
87
  'aa29f37ab7f4b2cf',
97
88
  ],
98
89
  },
@@ -108,6 +99,28 @@ All fields are optional. Omit a section entirely to deny that capability. The sc
108
99
 
109
100
  ---
110
101
 
102
+ ## For institutions
103
+
104
+ The cryptography is free under Apache-2.0, works offline and needs nothing from
105
+ KXCO, now or in ten years. What KXCO sells is the part that has to be operated:
106
+ an answer about the present.
107
+
108
+ | Service | What you get |
109
+ |---|---|
110
+ | Hosted key registry | Whether a key is active, revoked or rotated, answered at verification time |
111
+ | Meta-transaction relay | KXCO validates your signed intent, pays the gas and submits it, so you never hold a token or run a node |
112
+ | On-chain anchoring | A timestamp on Armature L1 that the chain itself has verified |
113
+ | Live revocation | `anchored+live` verification, which confirms the signing key is still trusted now |
114
+ | Support and SLA | Availability commitments, an escalation path and a named contact |
115
+
116
+ Priced in USD, per seat, per year. No tokens, no nodes and no wallets. The line
117
+ between free and paid is set out in
118
+ [LICENCE-PRODUCT.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/LICENCE-PRODUCT.md).
119
+
120
+ **Talk to us: [admin@kxco.ai](mailto:admin@kxco.ai)** · [kxco.ai](https://kxco.ai)
121
+
122
+ ---
123
+
111
124
  ## API
112
125
 
113
126
  ### KxcoAgentIdentity
@@ -119,9 +132,9 @@ Generates an ML-DSA-65 keypair for the agent and has the sponsor sign the creden
119
132
  | Option | Type | Required | Description |
120
133
  |--------|------|----------|-------------|
121
134
  | `sponsor` | `{ kid: string, sign(msg: Uint8Array): Promise<Uint8Array> }` | Yes | The sponsoring KxcoIdentity |
122
- | `label` | `string` | Yes | Human-readable name for this agent |
135
+ | `label` | `string` | Yes | Human-readable name for this agent, one line of text |
123
136
  | `agentType` | `'llm' \| 'robot' \| 'iot' \| 'process'` | Yes | Category of the agent |
124
- | `model` | `string` | No | Model, firmware, or version identifier |
137
+ | `model` | `string` | No | Model, firmware, or version identifier, one line of text |
125
138
  | `scope` | `object` | Yes | Capability manifest (see above) |
126
139
  | `expiresIn` | `string \| number` | Yes | Duration: `'30d'`, `'1y'`, or seconds as a number |
127
140
  | `chain` | `KxcoChain` | No | If provided, registers the credential on-chain at issuance |
@@ -132,7 +145,7 @@ Returns a relay client that automatically attaches the agent's credential and si
132
145
 
133
146
  | Option | Type | Default | Description |
134
147
  |--------|------|---------|-------------|
135
- | `relay` | `string` | — | Relay base URL, e.g. `'https://relay.kxco.ai'` |
148
+ | `relay` | `string` | none | Relay base URL, e.g. `'https://relay.kxco.ai'` |
136
149
  | `opts.timeout` | `number` | `10000` | Request timeout in milliseconds |
137
150
 
138
151
  **`agent.sign(message)` → `Promise<Uint8Array>`**
@@ -176,6 +189,42 @@ Anchors an audit log checkpoint on-chain. Requires `scope.auditLog: true`.
176
189
 
177
190
  Submits a payment intent. `to` is an EVM address or KXCO kid. `amount` is in ARMR. The relay enforces `allowedRecipients`, `maxPerTransaction`, and `maxPerDay` from the scope.
178
191
 
192
+ ### `checkScope(scope, action)`
193
+
194
+ Decide whether a scope permits an action, before attempting it.
195
+
196
+ ```js
197
+ import { checkScope } from 'kxco-pq-agent'
198
+
199
+ // agent.scope grants payments with maxPerTransaction 5000 and maxPerDay 15000
200
+ checkScope(agent.scope, {
201
+ type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
202
+ })
203
+ // { allowed: false,
204
+ // reason: "amount 4500 would take today's total to 16500, past maxPerDay 15000",
205
+ // checked: ['payments.enabled', 'payments.maxPerTransaction', 'payments.maxPerDay'] }
206
+ ```
207
+
208
+ The relay enforces the same signed scope behind the agent, and that enforcement
209
+ is the one that binds. This runs in front of it: it refuses offline, refuses
210
+ without spending a round trip, and names the limit that stopped it.
211
+
212
+ | Action | Fields | Checked against |
213
+ |---|---|---|
214
+ | `payment` | `amount`, `recipient`, `spentToday` | `maxPerTransaction`, `maxPerDay`, `allowedRecipients` |
215
+ | `attestation` | `purpose` | `attestations.purposes` |
216
+ | `auditLog` | none | `auditLog` |
217
+ | `credentials` | none | `credentials` |
218
+
219
+ Returns `{ allowed, reason?, checked }`. `checked` lists every limit the
220
+ decision actually evaluated, so a caller can show the control ran.
221
+
222
+ It fails closed: a capability the scope does not grant is denied, an action type
223
+ it does not recognise is denied, and a configured limit that cannot be judged
224
+ from the inputs given is denied rather than skipped. Set `maxPerDay` and omit
225
+ `spentToday` and the answer is a refusal naming the missing input, never a pass
226
+ that skipped the cap.
227
+
179
228
  ---
180
229
 
181
230
  ## Agent types
@@ -209,18 +258,62 @@ attributable to a legal entity when a supervisor asks who authorised it.
209
258
 
210
259
  ---
211
260
 
212
- ## Part of the KXCO stack
261
+ ## The KXCO post-quantum family
262
+
263
+ This gives an agent an identity a verified institution answers for. The rest of the family covers the jobs around it:
264
+
265
+ | You need to | Install |
266
+ |---|---|
267
+ | Put the whole stack in one install | [`kxco-pq`](https://www.npmjs.com/package/kxco-pq) |
268
+ | Use ML-DSA, ML-KEM and SLH-DSA directly | [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) |
269
+ | Keep signing keys on the HSM you already run | [`kxco-pq-hsm`](https://www.npmjs.com/package/kxco-pq-hsm) |
270
+ | Sign a document or record anyone can verify offline | [`kxco-pq-attest`](https://www.npmjs.com/package/kxco-pq-attest) |
271
+ | Keep a tamper-evident audit trail | [`kxco-pq-audit`](https://www.npmjs.com/package/kxco-pq-audit) |
272
+ | Verify a signature in a browser, with no server | [`kxco-verify`](https://www.npmjs.com/package/kxco-verify) |
273
+ | Issue institution identity credentials | [`kxco-pq-sdk`](https://www.npmjs.com/package/kxco-pq-sdk) |
274
+ | Encrypt files and payloads to one or many recipients | [`kxco-pq-vault`](https://www.npmjs.com/package/kxco-pq-vault) |
275
+ | Encrypt Node streams and WebSockets | [`kxco-pq-tls`](https://www.npmjs.com/package/kxco-pq-tls) |
276
+ | Sign and verify webhooks | [`kxco-post-quantum-webhook`](https://www.npmjs.com/package/kxco-post-quantum-webhook) |
277
+ | Give an AI agent an identity a verified institution sponsors | [`kxco-pq-agent`](https://www.npmjs.com/package/kxco-pq-agent) |
278
+ | Have Armature L1 verify a signature in consensus | [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain) |
279
+ | Prove an envelope at three levels, offline to on-chain | [`kxco-pq-network`](https://www.npmjs.com/package/kxco-pq-network) |
280
+ | Generate and rotate keys from a terminal | [`kxco-pq-cli`](https://www.npmjs.com/package/kxco-pq-cli) |
281
+ | Find quantum-vulnerable cryptography in a dependency tree | [`kxco-pq-scan`](https://www.npmjs.com/package/kxco-pq-scan) |
282
+ | Fail the build when code reaches past the wrapper | [`eslint-plugin-kxco-pq`](https://www.npmjs.com/package/eslint-plugin-kxco-pq) |
213
283
 
214
- | Package | Purpose |
215
- |---------|---------|
216
- | `kxco-post-quantum` | ML-DSA-65 and ML-KEM primitives |
217
- | `kxco-pq-sdk` | KxcoIdentity — human and institution identities |
218
- | `kxco-pq-agent` | This package — machine and agent identities |
284
+ ## Release integrity
219
285
 
220
- Contact: hello@kxco.ai | https://kxco.ai
286
+ Every release since 1.0.7 carries a SLSA provenance attestation tying the published tarball to
287
+ the commit and workflow that built it: verify with `npm audit signatures`, or read
288
+ it from `registry.npmjs.org/-/npm/v1/attestations/kxco-pq-agent@<version>`. A CycloneDX
289
+ SBOM is published, from v1.0.7, as a GitHub Release asset at
290
+ `releases/download/v<version>/sbom.cyclonedx.json`, a permanent unauthenticated
291
+ URL. Sibling `kxco-*` packages sit on caret ranges so a correctness fix in the
292
+ base package reaches you on the next install, with no release of every package
293
+ above it.
221
294
 
222
- ---
295
+ ## Security
296
+
297
+ Agents sign with **ML-DSA-65** (NIST FIPS 204) via [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), running on the OpenSSL 3.5 primitives where the runtime provides them. No custom cryptography.
298
+
299
+ Evidenced, and reproducible on your own machine:
300
+
301
+ - **1,793 NIST ACVP vectors passed, 0 failed** across FIPS 203, 204 and 205, pinned by digest, per [CONFORMANCE.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md). The other 310 are pairings the library refuses as weaker than the parameter set
302
+ - **225 interoperability checks passed, 0 failed**, against OpenSSL 3.5, liboqs, Bouncy Castle and dilithium-py/kyber-py, in both directions
303
+ - **SLSA provenance** on every release since 1.0.7: verify with `npm audit signatures`
304
+ - **CycloneDX SBOM** published with every release since 1.0.7
305
+ - `npm run evidence` records this package's identity, its own test run, its SBOM and the `kxco-post-quantum` version actually installed
306
+
307
+ Dependency audit history is recorded in [AUDIT.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/AUDIT.md).
308
+
309
+ The sponsor's signature covers the agent's key id and public key, the sponsor's key id, the agent type, label, model, scope, issue time and expiry, so a credential with any field changed fails `KxcoAgentIdentity.verify()` against the sponsor's key.
310
+
311
+ To report a vulnerability, email **security@kxco.ai**.
312
+
313
+ ## License
314
+
315
+ Apache-2.0 © 2026 Knightsbridge Financial Ltd, trading as KXCO. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
223
316
 
224
- ## Authors
317
+ ## Maintainers
225
318
 
226
- Shayne Heffernan and John Heffernan — KXCO by Knightsbridge
319
+ Shayne Heffernan and John Heffernan, [KXCO by Knightsbridge](https://kxco.ai)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kxco-pq-agent",
3
- "version": "1.0.9",
4
- "description": "Post-quantum identity for AI agents and autonomous systems: a KYC-verified institution sponsors an ML-DSA-65 keypair and locked capability scope for any agent that cannot pass KYC itself.",
3
+ "version": "1.1.1",
4
+ "description": "Post-quantum identity for AI agents: a KYC-verified institution sponsors an ML-DSA-65 key and a locked capability scope, enforced at the relay and offline by checkScope(). For LLMs, robots, IoT devices and daemons, with mandatory expiry.",
5
5
  "keywords": [
6
6
  "post-quantum",
7
7
  "pqc",
@@ -16,7 +16,16 @@
16
16
  "capability-scope",
17
17
  "armature",
18
18
  "relay",
19
- "blockchain"
19
+ "blockchain",
20
+ "pqc-migration",
21
+ "crypto-agility",
22
+ "digital-signature",
23
+ "non-repudiation",
24
+ "agentic-ai",
25
+ "autonomous-agents",
26
+ "llm-agent",
27
+ "kyc",
28
+ "delegated-authority"
20
29
  ],
21
30
  "license": "Apache-2.0",
22
31
  "author": "Shayne Heffernan and John Heffernan",
@@ -47,8 +56,10 @@
47
56
  }
48
57
  },
49
58
  "files": [
59
+ "CHANGELOG.md",
50
60
  "src",
51
61
  "LICENSE",
62
+ "NOTICE",
52
63
  "ASSESSMENT.md"
53
64
  ],
54
65
  "engines": {
@@ -58,11 +69,15 @@
58
69
  "kxco-post-quantum": "^1.3.0"
59
70
  },
60
71
  "scripts": {
61
- "test": "node --test --test-timeout=30000 test/agent.test.js",
72
+ "test": "node --test --test-timeout=30000 test/agent.test.js test/property.test.js",
62
73
  "evidence": "node scripts/build-evidence.mjs"
63
74
  },
64
75
  "funding": "https://kxco.ai",
65
76
  "publishConfig": {
77
+ "provenance": true,
66
78
  "access": "public"
79
+ },
80
+ "devDependencies": {
81
+ "fast-check": "4.10.2"
67
82
  }
68
83
  }
@@ -14,15 +14,33 @@ async function sha256Hex(str) {
14
14
  return Buffer.from(buf).toString('hex')
15
15
  }
16
16
 
17
+ // Each header is one line, so a field carrying a line break could pass for the
18
+ // end of one header and the start of the next, and two different intents would
19
+ // sign the same bytes. An unpaired surrogate has no UTF-8 form: TextEncoder
20
+ // writes every one as U+FFFD, so it is refused for the same reason. JSON
21
+ // escapes both inside the payload, so only the header fields need the check.
22
+ const UNSAFE_HEADER = /[\r\n]|\p{Cs}/u
23
+
24
+ function header(name, value) {
25
+ const text = `${value}`
26
+ if (UNSAFE_HEADER.test(text)) {
27
+ throw new KxcoPqAgentError(
28
+ `${name} must not contain a line break or an unpaired surrogate`,
29
+ { code: 'BAD_ARGUMENT' },
30
+ )
31
+ }
32
+ return `${name}: ${text}`
33
+ }
34
+
17
35
  function buildSigningMessage(operation, agentKid, sponsorKid, nonce, timestamp, credentialHash, payload) {
18
36
  return enc.encode([
19
37
  'kxco-relay-agent-v1',
20
- `operation: ${operation}`,
21
- `agentKid: ${agentKid}`,
22
- `sponsorKid: ${sponsorKid}`,
23
- `nonce: ${nonce}`,
24
- `timestamp: ${timestamp}`,
25
- `credentialHash: ${credentialHash}`,
38
+ header('operation', operation),
39
+ header('agentKid', agentKid),
40
+ header('sponsorKid', sponsorKid),
41
+ header('nonce', nonce),
42
+ header('timestamp', timestamp),
43
+ header('credentialHash', credentialHash),
26
44
  `payload: ${canonicalize(payload)}`,
27
45
  ].join('\n'))
28
46
  }
@@ -45,6 +63,7 @@ export class AgentChainClient {
45
63
 
46
64
  constructor({ relay, agent, timeout = 10_000 }) {
47
65
  if (!relay) throw new KxcoPqAgentError('relay URL is required', { code: 'BAD_CONFIG' })
66
+ if (typeof relay !== 'string') throw new KxcoPqAgentError('relay must be a URL string', { code: 'BAD_CONFIG' })
48
67
  if (!agent) throw new KxcoPqAgentError('agent is required', { code: 'BAD_CONFIG' })
49
68
  this.#relay = relay.replace(/\/$/, '')
50
69
  this.#agent = agent
@@ -41,6 +41,26 @@ function credentialSigningMsg({ agentKid, agentPublicKey, sponsorKid, agentType,
41
41
  ].join('\n'))
42
42
  }
43
43
 
44
+ // The sponsor-signed message is one field per line, so a field carrying a
45
+ // line break could hand part of its text to its neighbour, and two different
46
+ // credentials would sign the same bytes. An unpaired surrogate has no UTF-8
47
+ // form (TextEncoder writes every one as U+FFFD), so it is refused as well.
48
+ const UNSAFE_FIELD = /[\r\n]|\p{Cs}/u
49
+
50
+ // Why the first of these fields cannot go into the message, or null.
51
+ function fieldFault(fields) {
52
+ for (const [name, value] of Object.entries(fields)) {
53
+ let text
54
+ try {
55
+ text = value == null ? '' : `${value}`
56
+ } catch {
57
+ return `${name} cannot be written as text`
58
+ }
59
+ if (UNSAFE_FIELD.test(text)) return `${name} must not contain a line break or an unpaired surrogate`
60
+ }
61
+ return null
62
+ }
63
+
44
64
  const VALID_AGENT_TYPES = new Set(['llm', 'robot', 'iot', 'process'])
45
65
 
46
66
  export class KxcoAgentIdentity {
@@ -104,13 +124,20 @@ export class KxcoAgentIdentity {
104
124
  if (!scope) throw new KxcoPqAgentError('create: scope is required')
105
125
  if (expiresIn == null) throw new KxcoPqAgentError('create: expiresIn is required — agents must have an expiry')
106
126
 
127
+ const fault = fieldFault({ label, model, sponsorKid: sponsor.kid })
128
+ if (fault) throw new KxcoPqAgentError(`create: ${fault}`)
129
+
107
130
  validateScope(scope)
108
131
 
109
132
  const keypair = mlDsa.ml_dsa65.keygen()
110
133
  const agentKid = fingerprint(keypair.publicKey)
111
134
  const agentPubB64 = b64url(keypair.publicKey)
112
135
  const issuedAt = new Date().toISOString()
113
- const expiresAt = new Date(Date.now() + parseDuration(expiresIn)).toISOString()
136
+ const expiry = new Date(Date.now() + parseDuration(expiresIn))
137
+ if (Number.isNaN(expiry.getTime())) {
138
+ throw new KxcoPqAgentError(`create: expiresIn '${expiresIn}' does not give a representable expiry date`)
139
+ }
140
+ const expiresAt = expiry.toISOString()
114
141
 
115
142
  const sigMsg = credentialSigningMsg({
116
143
  agentKid,
@@ -204,6 +231,9 @@ export class KxcoAgentIdentity {
204
231
  if (!exported || exported['kxco-agent-identity'] !== IDENTITY_VERSION) {
205
232
  throw new KxcoPqAgentError('import: invalid or unsupported agent identity format')
206
233
  }
234
+ // The restored agent acts on this scope, so it is held to the rules
235
+ // create() applied.
236
+ validateScope(exported.scope)
207
237
  return new KxcoAgentIdentity({
208
238
  kid: exported.kid,
209
239
  keypair: { secretKey: fromB64url(exported.secretKey), publicKey: fromB64url(exported.publicKey) },
@@ -254,7 +284,24 @@ export class KxcoAgentIdentity {
254
284
  return { valid: false, error: 'malformed credential — missing required fields' }
255
285
  }
256
286
 
257
- if (new Date(expiresAt) < new Date()) {
287
+ const fault = fieldFault({ agentKid, agentPublicKey, sponsorKid, agentType, label, model, issuedAt, expiresAt })
288
+ if (fault) return { valid: false, error: `malformed credential: ${fault}` }
289
+
290
+ // The scope is returned for the caller to act on, so it is held to the
291
+ // rules create() applied. That also means it has a JCS form to check the
292
+ // signature over.
293
+ try {
294
+ validateScope(scope)
295
+ } catch (err) {
296
+ return { valid: false, error: `malformed credential: ${err.message}` }
297
+ }
298
+
299
+ // An expiry that is not a date cannot be shown to lie in the future.
300
+ const expiry = new Date(expiresAt).getTime()
301
+ if (Number.isNaN(expiry)) {
302
+ return { valid: false, error: 'agent credential has no valid expiry, so it is treated as expired' }
303
+ }
304
+ if (expiry < Date.now()) {
258
305
  return { valid: false, error: 'agent credential has expired' }
259
306
  }
260
307
 
package/src/index.d.ts CHANGED
@@ -24,7 +24,10 @@ export interface PaymentScope {
24
24
 
25
25
  export interface AttestationScope {
26
26
  enabled: boolean
27
- /** Allowed purposes. Empty array = any purpose. */
27
+ /**
28
+ * Allowed purposes. Empty array = no purpose is allowed (checkScope fails
29
+ * closed). Absent / undefined = any purpose.
30
+ */
28
31
  purposes?: string[]
29
32
  }
30
33
 
@@ -38,6 +41,30 @@ export interface AgentScope {
38
41
  export function validateScope(scope: AgentScope): AgentScope
39
42
  export function hashScope(scope: AgentScope): Promise<string>
40
43
 
44
+ export type ScopeAction =
45
+ | { type: 'payment'; amount: number; recipient?: string; spentToday?: number }
46
+ | { type: 'attestation'; purpose?: string }
47
+ | { type: 'auditLog' }
48
+ | { type: 'credentials' }
49
+
50
+ export interface ScopeDecision {
51
+ allowed: boolean
52
+ /** Why the action was refused. Absent when allowed. */
53
+ reason?: string
54
+ /** Every limit the decision actually evaluated, in the order applied. */
55
+ checked: string[]
56
+ }
57
+
58
+ /**
59
+ * Decide whether a scope permits an action, before it is attempted.
60
+ *
61
+ * Runs in front of the relay's own enforcement, on the same signed scope: it
62
+ * refuses offline, refuses without spending a round trip, and names the limit
63
+ * that stopped it. Fails closed on an ungranted capability, an unknown action
64
+ * type, and a configured limit that cannot be evaluated from the inputs given.
65
+ */
66
+ export function checkScope(scope: AgentScope, action: ScopeAction): ScopeDecision
67
+
41
68
  // ── Credential envelope ───────────────────────────────────────────────────────
42
69
 
43
70
  export interface AgentCredential {
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { KxcoAgentIdentity } from './agent-identity.js'
2
2
  export { AgentChainClient } from './agent-client.js'
3
3
  export { KxcoPqAgentError } from './errors.js'
4
- export { validateScope, hashScope } from './scope.js'
4
+ export { validateScope, checkScope, hashScope } from './scope.js'
package/src/jcs.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // RFC 8785 JSON Canonicalization Scheme (JCS), subset.
2
- // Copied from kxco-pq-cli/src/jcs.js — do not diverge.
2
+ // Copied from kxco-pq-cli/src/jcs.js, with two differences. It throws
3
+ // KxcoPqAgentError, and it refuses an object key named "__proto__" (see walk()).
4
+ // Otherwise its output must not change: the relay recomputes these bytes with
5
+ // the same algorithm to check the sponsor's signature and the scope hash.
6
+
7
+ import { KxcoPqAgentError } from './errors.js'
3
8
 
4
9
  /**
5
10
  * Canonicalize a JSON-serializable value per (a subset of) RFC 8785.
@@ -15,14 +20,19 @@ function walk(v) {
15
20
  if (typeof v === 'boolean') return v
16
21
  if (typeof v === 'string') return v
17
22
  if (typeof v === 'number') {
18
- if (!Number.isFinite(v)) throw new TypeError('JCS: non-finite numbers are not representable in JSON')
19
- if (!Number.isInteger(v)) throw new TypeError('JCS subset: floats are not supported')
23
+ if (!Number.isFinite(v)) throw new KxcoPqAgentError('JCS: non-finite numbers are not representable in JSON')
24
+ if (!Number.isInteger(v)) throw new KxcoPqAgentError('JCS subset: floats are not supported')
20
25
  return v
21
26
  }
22
27
  if (Array.isArray(v)) return v.map(walk)
23
28
  if (v && typeof v === 'object') {
24
29
  const out = {}
25
30
  for (const k of Object.keys(v).sort()) {
31
+ // Assigning "__proto__" on a plain object sets its prototype rather than
32
+ // adding a key, so the member would be left out of the signed bytes. It
33
+ // is refused, not serialised, so that the bytes stay the ones the relay
34
+ // computes.
35
+ if (k === '__proto__') throw new KxcoPqAgentError('JCS: an object key named "__proto__" is not supported')
26
36
  const child = walk(v[k])
27
37
  if (child === undefined) continue
28
38
  out[k] = child
@@ -30,5 +40,5 @@ function walk(v) {
30
40
  return out
31
41
  }
32
42
  if (v === undefined) return undefined
33
- throw new TypeError(`JCS: unsupported value of type ${typeof v}`)
43
+ throw new KxcoPqAgentError(`JCS: unsupported value of type ${typeof v}`)
34
44
  }
package/src/scope.js CHANGED
@@ -8,6 +8,14 @@ function validRecipient(s) {
8
8
  return EVM_RE.test(s) || KID_RE.test(s)
9
9
  }
10
10
 
11
+ // An `enabled` switch is either absent or a boolean. Only `false` switches a
12
+ // section off, so any other value would otherwise count as on.
13
+ function checkEnabled(name, section) {
14
+ if (section && typeof section === 'object' && section.enabled !== undefined && typeof section.enabled !== 'boolean') {
15
+ throw new KxcoPqAgentError(`scope.${name}.enabled must be a boolean`)
16
+ }
17
+ }
18
+
11
19
  /**
12
20
  * Validate a scope object. Throws KxcoPqAgentError on any violation.
13
21
  * Returns the scope unchanged.
@@ -23,17 +31,21 @@ export function validateScope(scope) {
23
31
  if (typeof payments !== 'object' || Array.isArray(payments)) {
24
32
  throw new KxcoPqAgentError('scope.payments must be an object')
25
33
  }
34
+ checkEnabled('payments', payments)
26
35
  if (payments.enabled !== false) {
27
36
  const { maxPerTransaction: mpt, maxPerDay: mpd, allowedRecipients: ar } = payments
28
37
 
38
+ // Whole numbers only: create() signs the JCS form of the scope, and the
39
+ // JCS subset here has no fractions. Number.isInteger also refuses NaN
40
+ // and Infinity, which no limit comparison can use.
29
41
  if (mpt !== undefined) {
30
- if (typeof mpt !== 'number' || mpt <= 0) {
31
- throw new KxcoPqAgentError('scope.payments.maxPerTransaction must be a positive number')
42
+ if (!Number.isInteger(mpt) || mpt <= 0) {
43
+ throw new KxcoPqAgentError('scope.payments.maxPerTransaction must be a positive integer')
32
44
  }
33
45
  }
34
46
  if (mpd !== undefined) {
35
- if (typeof mpd !== 'number' || mpd <= 0) {
36
- throw new KxcoPqAgentError('scope.payments.maxPerDay must be a positive number')
47
+ if (!Number.isInteger(mpd) || mpd <= 0) {
48
+ throw new KxcoPqAgentError('scope.payments.maxPerDay must be a positive integer')
37
49
  }
38
50
  }
39
51
  if (mpt !== undefined && mpd !== undefined && mpt > mpd) {
@@ -44,6 +56,9 @@ export function validateScope(scope) {
44
56
  throw new KxcoPqAgentError('scope.payments.allowedRecipients must be an array')
45
57
  }
46
58
  for (const r of ar) {
59
+ if (typeof r !== 'string') {
60
+ throw new KxcoPqAgentError(`each entry in scope.payments.allowedRecipients must be a string, not ${r === null ? 'null' : typeof r}`)
61
+ }
47
62
  if (!validRecipient(r)) {
48
63
  throw new KxcoPqAgentError(
49
64
  `invalid recipient '${r}' — must be an EVM address (0x + 40 hex) or KXCO kid (16 lowercase hex chars)`
@@ -58,6 +73,7 @@ export function validateScope(scope) {
58
73
  if (typeof attestations !== 'object' || Array.isArray(attestations)) {
59
74
  throw new KxcoPqAgentError('scope.attestations must be an object')
60
75
  }
76
+ checkEnabled('attestations', attestations)
61
77
  if (attestations.enabled !== false && attestations.purposes !== undefined) {
62
78
  if (!Array.isArray(attestations.purposes)) {
63
79
  throw new KxcoPqAgentError('scope.attestations.purposes must be an array of strings')
@@ -77,10 +93,176 @@ export function validateScope(scope) {
77
93
  if (credentials != null && typeof credentials !== 'boolean' && (typeof credentials !== 'object' || Array.isArray(credentials))) {
78
94
  throw new KxcoPqAgentError('scope.credentials must be a boolean or object')
79
95
  }
96
+ checkEnabled('auditLog', auditLog)
97
+ checkEnabled('credentials', credentials)
98
+
99
+ // create() signs the JCS form, so a scope that has none (a fraction
100
+ // anywhere in it, or a key JCS refuses) is refused here, before signing.
101
+ canonicalize(scope)
80
102
 
81
103
  return scope
82
104
  }
83
105
 
106
+ /**
107
+ * Decide whether a scope permits an action, before it is attempted.
108
+ *
109
+ * The relay enforces scope too, and that enforcement is the one that binds: it
110
+ * sits behind the agent, so a compromised agent cannot talk its way past it.
111
+ * This check runs in front, on the same signed scope, and it earns its place
112
+ * three ways. It refuses offline, so an agent that cannot reach the relay still
113
+ * knows what it may not do. It refuses immediately, without spending a network
114
+ * round trip to be told no. And it names the limit that stopped it, which a
115
+ * remote refusal cannot do as precisely.
116
+ *
117
+ * Fails closed throughout. A capability the scope does not grant is denied, an
118
+ * action type it does not recognise is denied, and a configured limit that
119
+ * cannot be evaluated from the inputs given is denied rather than skipped.
120
+ *
121
+ * @param {object} scope — the signed capability manifest
122
+ * @param {object} action
123
+ * @param {'payment'|'attestation'|'auditLog'|'credentials'} action.type
124
+ * @param {number} [action.amount] — payment only, ARMR
125
+ * @param {string} [action.recipient] — payment only, EVM address or kid
126
+ * @param {number} [action.spentToday] — payment only, required when
127
+ * `payments.maxPerDay` is set, because a day cap cannot be judged from one
128
+ * transaction
129
+ * @param {string} [action.purpose] — attestation only
130
+ * @returns {{ allowed: boolean, reason?: string, checked: string[] }}
131
+ */
132
+ export function checkScope(scope, action) {
133
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
134
+ throw new KxcoPqAgentError('checkScope: scope must be a plain object')
135
+ }
136
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
137
+ throw new KxcoPqAgentError('checkScope: action must be a plain object')
138
+ }
139
+
140
+ const checked = []
141
+ const deny = (reason) => ({ allowed: false, reason, checked })
142
+ const allow = () => ({ allowed: true, checked })
143
+
144
+ const granted = (section) =>
145
+ section != null && section !== false && section.enabled !== false
146
+
147
+ // A section or limit of the wrong type cannot be judged, so it is denied,
148
+ // naming it, rather than read loosely. validateScope refuses the same shapes;
149
+ // these checks cover a scope that never went through it.
150
+ const sectionFault = (name, section, booleanAllowed) => {
151
+ if (section == null || section === false) return null
152
+ if (section === true && booleanAllowed) return null
153
+ if (typeof section !== 'object' || Array.isArray(section)) {
154
+ return `scope.${name} must be ${booleanAllowed ? 'a boolean or an object' : 'an object'}`
155
+ }
156
+ if (section.enabled !== undefined && typeof section.enabled !== 'boolean') {
157
+ return `scope.${name}.enabled must be a boolean`
158
+ }
159
+ return null
160
+ }
161
+ const isLimit = (v) => Number.isFinite(v) && v > 0
162
+ const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string')
163
+
164
+ switch (action.type) {
165
+ case 'payment': {
166
+ const p = scope.payments
167
+ checked.push('payments.enabled')
168
+ const fault = sectionFault('payments', p, false)
169
+ if (fault) return deny(fault)
170
+ if (!granted(p)) return deny('scope does not grant payments')
171
+
172
+ if (!Number.isFinite(action.amount) || !(action.amount > 0)) {
173
+ return deny('payment amount must be a positive number')
174
+ }
175
+
176
+ if (p.maxPerTransaction !== undefined) {
177
+ checked.push('payments.maxPerTransaction')
178
+ if (!isLimit(p.maxPerTransaction)) {
179
+ return deny('payments.maxPerTransaction must be a positive number, so it cannot be evaluated')
180
+ }
181
+ if (action.amount > p.maxPerTransaction) {
182
+ return deny(
183
+ `amount ${action.amount} exceeds maxPerTransaction ${p.maxPerTransaction}`,
184
+ )
185
+ }
186
+ }
187
+
188
+ if (p.maxPerDay !== undefined) {
189
+ checked.push('payments.maxPerDay')
190
+ if (!isLimit(p.maxPerDay)) {
191
+ return deny('payments.maxPerDay must be a positive number, so it cannot be evaluated')
192
+ }
193
+ if (!Number.isFinite(action.spentToday) || action.spentToday < 0) {
194
+ return deny(
195
+ 'payments.maxPerDay is set, so spentToday is required to evaluate it',
196
+ )
197
+ }
198
+ if (action.spentToday + action.amount > p.maxPerDay) {
199
+ return deny(
200
+ `amount ${action.amount} would take today's total to ` +
201
+ `${action.spentToday + action.amount}, past maxPerDay ${p.maxPerDay}`,
202
+ )
203
+ }
204
+ }
205
+
206
+ if (p.allowedRecipients !== undefined) {
207
+ checked.push('payments.allowedRecipients')
208
+ if (!isStringList(p.allowedRecipients)) {
209
+ return deny('payments.allowedRecipients must be an array of strings, so it cannot be evaluated')
210
+ }
211
+ if (typeof action.recipient !== 'string') {
212
+ return deny('payments.allowedRecipients is set, so a recipient is required')
213
+ }
214
+ // EVM addresses are case-insensitive; kids are lowercase hex.
215
+ const want = action.recipient.toLowerCase()
216
+ const ok = p.allowedRecipients.some((r) => r.toLowerCase() === want)
217
+ if (!ok) return deny(`recipient ${action.recipient} is not in allowedRecipients`)
218
+ }
219
+
220
+ return allow()
221
+ }
222
+
223
+ case 'attestation': {
224
+ const a = scope.attestations
225
+ checked.push('attestations.enabled')
226
+ const fault = sectionFault('attestations', a, false)
227
+ if (fault) return deny(fault)
228
+ if (!granted(a)) return deny('scope does not grant attestations')
229
+
230
+ if (a.purposes !== undefined) {
231
+ checked.push('attestations.purposes')
232
+ if (!isStringList(a.purposes)) {
233
+ return deny('attestations.purposes must be an array of strings, so it cannot be evaluated')
234
+ }
235
+ if (typeof action.purpose !== 'string') {
236
+ return deny('attestations.purposes is set, so a purpose is required')
237
+ }
238
+ if (!a.purposes.includes(action.purpose)) {
239
+ return deny(`purpose '${action.purpose}' is not in attestations.purposes`)
240
+ }
241
+ }
242
+ return allow()
243
+ }
244
+
245
+ case 'auditLog': {
246
+ checked.push('auditLog')
247
+ const fault = sectionFault('auditLog', scope.auditLog, true)
248
+ if (fault) return deny(fault)
249
+ return granted(scope.auditLog) ? allow() : deny('scope does not grant auditLog')
250
+ }
251
+
252
+ case 'credentials': {
253
+ checked.push('credentials')
254
+ const fault = sectionFault('credentials', scope.credentials, true)
255
+ if (fault) return deny(fault)
256
+ return granted(scope.credentials)
257
+ ? allow()
258
+ : deny('scope does not grant credentials')
259
+ }
260
+
261
+ default:
262
+ return deny(`unknown action type '${action.type}'`)
263
+ }
264
+ }
265
+
84
266
  /**
85
267
  * Compute a hex SHA-256 of the JCS-canonical scope.
86
268
  * This hash is stored on-chain so the relay can verify scope integrity.