kxco-pq-agent 1.0.7 → 1.1.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/ASSESSMENT.md ADDED
@@ -0,0 +1,117 @@
1
+ # Assessment notes
2
+
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
+
6
+ Algorithm conformance belongs to
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', …] }
43
+ ```
44
+
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
59
+
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.
64
+
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.
73
+
74
+ ## Agility
75
+
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.
83
+
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.
87
+
88
+ ## Running it
89
+
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.
94
+
95
+ **Supported versions.** One line moving forward. Fixes land in the next release.
96
+
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.
100
+
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:
104
+
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
+ ```
109
+
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.
112
+
113
+ ## Correcting this document
114
+
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,41 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ **`checkScope()` enforces the scope locally, before the relay sees the action.**
6
+
7
+ ```js
8
+ import { checkScope } from 'kxco-pq-agent'
9
+
10
+ const decision = checkScope(agent.scope, {
11
+ type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
12
+ })
13
+ // { allowed: false,
14
+ // reason: "amount 4500 would take today's total to 16500, past maxPerDay 15000",
15
+ // checked: ['payments.enabled', 'payments.maxPerTransaction', 'payments.maxPerDay'] }
16
+ ```
17
+
18
+ The relay's enforcement is unchanged and is still the one that binds, because it
19
+ sits behind the agent where a compromised agent cannot reach it. This runs in
20
+ front, on the same signed scope, and earns its place three ways: it refuses
21
+ offline, so an agent that cannot reach the relay still knows what it may not do;
22
+ it refuses without spending a round trip; and it names the limit that stopped
23
+ it, which a remote refusal cannot do as precisely.
24
+
25
+ **Fails closed throughout.** A capability the scope does not grant is denied. An
26
+ action type it does not recognise is denied. A configured limit that cannot be
27
+ judged from the inputs given is denied rather than skipped — pass a scope with
28
+ `maxPerDay` set and no `spentToday`, and the answer is a refusal that says which
29
+ input is missing, not a pass that quietly skipped a control.
30
+
31
+ `checked` reports every limit the decision actually evaluated, in the order
32
+ applied, so a caller can show the control ran rather than assert it did.
33
+
34
+ Covers payments (`maxPerTransaction`, `maxPerDay`, `allowedRecipients`, with EVM
35
+ recipients matched case-insensitively), attestation purposes, `auditLog` and
36
+ `credentials`. Eleven tests.
37
+
38
+ **ASSESSMENT.md rewritten** to lead with the containment properties — an agent
39
+ cannot widen its own authority, cannot mint another agent, and always traces to
40
+ a KYC-verified institution — and to record that enforcement now runs at both
41
+ ends.
package/README.md CHANGED
@@ -4,6 +4,33 @@ Post-quantum identity for AI agents and autonomous systems.
4
4
 
5
5
  ---
6
6
 
7
+ ## Release integrity
8
+
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
+
7
34
  ## The problem it solves
8
35
 
9
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.
@@ -149,6 +176,41 @@ Anchors an audit log checkpoint on-chain. Requires `scope.auditLog: true`.
149
176
 
150
177
  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.
151
178
 
179
+ ### `checkScope(scope, action)`
180
+
181
+ Decide whether a scope permits an action, before attempting it.
182
+
183
+ ```js
184
+ import { checkScope } from 'kxco-pq-agent'
185
+
186
+ checkScope(agent.scope, {
187
+ type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
188
+ })
189
+ // { allowed: false,
190
+ // reason: "amount 4500 would take today's total to 16500, past maxPerDay 15000",
191
+ // checked: ['payments.enabled', 'payments.maxPerTransaction', 'payments.maxPerDay'] }
192
+ ```
193
+
194
+ The relay enforces the same signed scope behind the agent, and that enforcement
195
+ is the one that binds. This runs in front of it: it refuses offline, refuses
196
+ without spending a round trip, and names the limit that stopped it.
197
+
198
+ | Action | Fields | Checked against |
199
+ |---|---|---|
200
+ | `payment` | `amount`, `recipient`, `spentToday` | `maxPerTransaction`, `maxPerDay`, `allowedRecipients` |
201
+ | `attestation` | `purpose` | `attestations.purposes` |
202
+ | `auditLog` | — | `auditLog` |
203
+ | `credentials` | — | `credentials` |
204
+
205
+ Returns `{ allowed, reason?, checked }`. `checked` lists every limit the
206
+ decision actually evaluated, so a caller can show the control ran.
207
+
208
+ It fails closed: a capability the scope does not grant is denied, an action type
209
+ it does not recognise is denied, and a configured limit that cannot be judged
210
+ from the inputs given is denied rather than skipped. Set `maxPerDay` and omit
211
+ `spentToday` and the answer is a refusal naming the missing input, never a pass
212
+ that skipped the cap.
213
+
152
214
  ---
153
215
 
154
216
  ## Agent types
@@ -162,11 +224,23 @@ Submits a payment intent. `to` is an EVM address or KXCO kid. `amount` is in ARM
162
224
 
163
225
  ---
164
226
 
165
- ## What this does NOT do
227
+ ## The containment model
228
+
229
+ Three properties hold for every agent identity, and they are the reason to use
230
+ this rather than handing an agent a key.
231
+
232
+ **An agent can never widen its own authority.** The capability scope is fixed
233
+ at issuance and enforced relay-side, so an out-of-scope operation is refused
234
+ before it reaches the chain. Compromising the agent does not enlarge what the
235
+ agent may do.
236
+
237
+ **An agent can never mint another agent.** Only a KYC-verified sponsor issues
238
+ an identity, so there is no path from one compromised agent to a population of
239
+ them.
166
240
 
167
- - Agents cannot issue credentials to other agents. Only a KYC-verified sponsor can create an agent identity.
168
- - Agents cannot exceed the scope declared at issuance. The relay enforces scope server-side; attempting an out-of-scope operation returns an error.
169
- - Agents cannot operate without a sponsor. There is no anonymous or self-signed credential mode.
241
+ **Every agent traces to a named, KYC-verified institution.** There is no
242
+ anonymous or self-signed mode, which is what makes an agent's action
243
+ attributable to a legal entity when a supervisor asks who authorised it.
170
244
 
171
245
  ---
172
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-pq-agent",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
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.",
5
5
  "keywords": [
6
6
  "post-quantum",
@@ -47,8 +47,10 @@
47
47
  }
48
48
  },
49
49
  "files": [
50
+ "CHANGELOG.md",
50
51
  "src",
51
- "LICENSE"
52
+ "LICENSE",
53
+ "ASSESSMENT.md"
52
54
  ],
53
55
  "engines": {
54
56
  "node": ">=20.19"
@@ -57,7 +59,8 @@
57
59
  "kxco-post-quantum": "^1.3.0"
58
60
  },
59
61
  "scripts": {
60
- "test": "node --test --test-timeout=30000 test/agent.test.js"
62
+ "test": "node --test --test-timeout=30000 test/agent.test.js",
63
+ "evidence": "node scripts/build-evidence.mjs"
61
64
  },
62
65
  "funding": "https://kxco.ai",
63
66
  "publishConfig": {
package/src/index.d.ts CHANGED
@@ -38,6 +38,30 @@ export interface AgentScope {
38
38
  export function validateScope(scope: AgentScope): AgentScope
39
39
  export function hashScope(scope: AgentScope): Promise<string>
40
40
 
41
+ export type ScopeAction =
42
+ | { type: 'payment'; amount: number; recipient?: string; spentToday?: number }
43
+ | { type: 'attestation'; purpose?: string }
44
+ | { type: 'auditLog' }
45
+ | { type: 'credentials' }
46
+
47
+ export interface ScopeDecision {
48
+ allowed: boolean
49
+ /** Why the action was refused. Absent when allowed. */
50
+ reason?: string
51
+ /** Every limit the decision actually evaluated, in the order applied. */
52
+ checked: string[]
53
+ }
54
+
55
+ /**
56
+ * Decide whether a scope permits an action, before it is attempted.
57
+ *
58
+ * Runs in front of the relay's own enforcement, on the same signed scope: it
59
+ * refuses offline, refuses without spending a round trip, and names the limit
60
+ * that stopped it. Fails closed on an ungranted capability, an unknown action
61
+ * type, and a configured limit that cannot be evaluated from the inputs given.
62
+ */
63
+ export function checkScope(scope: AgentScope, action: ScopeAction): ScopeDecision
64
+
41
65
  // ── Credential envelope ───────────────────────────────────────────────────────
42
66
 
43
67
  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/scope.js CHANGED
@@ -81,6 +81,127 @@ export function validateScope(scope) {
81
81
  return scope
82
82
  }
83
83
 
84
+ /**
85
+ * Decide whether a scope permits an action, before it is attempted.
86
+ *
87
+ * The relay enforces scope too, and that enforcement is the one that binds: it
88
+ * sits behind the agent, so a compromised agent cannot talk its way past it.
89
+ * This check runs in front, on the same signed scope, and it earns its place
90
+ * three ways. It refuses offline, so an agent that cannot reach the relay still
91
+ * knows what it may not do. It refuses immediately, without spending a network
92
+ * round trip to be told no. And it names the limit that stopped it, which a
93
+ * remote refusal cannot do as precisely.
94
+ *
95
+ * Fails closed throughout. A capability the scope does not grant is denied, an
96
+ * action type it does not recognise is denied, and a configured limit that
97
+ * cannot be evaluated from the inputs given is denied rather than skipped.
98
+ *
99
+ * @param {object} scope — the signed capability manifest
100
+ * @param {object} action
101
+ * @param {'payment'|'attestation'|'auditLog'|'credentials'} action.type
102
+ * @param {number} [action.amount] — payment only, ARMR
103
+ * @param {string} [action.recipient] — payment only, EVM address or kid
104
+ * @param {number} [action.spentToday] — payment only, required when
105
+ * `payments.maxPerDay` is set, because a day cap cannot be judged from one
106
+ * transaction
107
+ * @param {string} [action.purpose] — attestation only
108
+ * @returns {{ allowed: boolean, reason?: string, checked: string[] }}
109
+ */
110
+ export function checkScope(scope, action) {
111
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
112
+ throw new KxcoPqAgentError('checkScope: scope must be a plain object')
113
+ }
114
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
115
+ throw new KxcoPqAgentError('checkScope: action must be a plain object')
116
+ }
117
+
118
+ const checked = []
119
+ const deny = (reason) => ({ allowed: false, reason, checked })
120
+ const allow = () => ({ allowed: true, checked })
121
+
122
+ const granted = (section) =>
123
+ section != null && section !== false && section.enabled !== false
124
+
125
+ switch (action.type) {
126
+ case 'payment': {
127
+ const p = scope.payments
128
+ checked.push('payments.enabled')
129
+ if (!granted(p)) return deny('scope does not grant payments')
130
+
131
+ if (typeof action.amount !== 'number' || !(action.amount > 0)) {
132
+ return deny('payment amount must be a positive number')
133
+ }
134
+
135
+ if (p.maxPerTransaction !== undefined) {
136
+ checked.push('payments.maxPerTransaction')
137
+ if (action.amount > p.maxPerTransaction) {
138
+ return deny(
139
+ `amount ${action.amount} exceeds maxPerTransaction ${p.maxPerTransaction}`,
140
+ )
141
+ }
142
+ }
143
+
144
+ if (p.maxPerDay !== undefined) {
145
+ checked.push('payments.maxPerDay')
146
+ if (typeof action.spentToday !== 'number' || action.spentToday < 0) {
147
+ return deny(
148
+ 'payments.maxPerDay is set, so spentToday is required to evaluate it',
149
+ )
150
+ }
151
+ if (action.spentToday + action.amount > p.maxPerDay) {
152
+ return deny(
153
+ `amount ${action.amount} would take today's total to ` +
154
+ `${action.spentToday + action.amount}, past maxPerDay ${p.maxPerDay}`,
155
+ )
156
+ }
157
+ }
158
+
159
+ if (p.allowedRecipients !== undefined) {
160
+ checked.push('payments.allowedRecipients')
161
+ if (typeof action.recipient !== 'string') {
162
+ return deny('payments.allowedRecipients is set, so a recipient is required')
163
+ }
164
+ // EVM addresses are case-insensitive; kids are lowercase hex.
165
+ const want = action.recipient.toLowerCase()
166
+ const ok = p.allowedRecipients.some((r) => r.toLowerCase() === want)
167
+ if (!ok) return deny(`recipient ${action.recipient} is not in allowedRecipients`)
168
+ }
169
+
170
+ return allow()
171
+ }
172
+
173
+ case 'attestation': {
174
+ const a = scope.attestations
175
+ checked.push('attestations.enabled')
176
+ if (!granted(a)) return deny('scope does not grant attestations')
177
+
178
+ if (a.purposes !== undefined) {
179
+ checked.push('attestations.purposes')
180
+ if (typeof action.purpose !== 'string') {
181
+ return deny('attestations.purposes is set, so a purpose is required')
182
+ }
183
+ if (!a.purposes.includes(action.purpose)) {
184
+ return deny(`purpose '${action.purpose}' is not in attestations.purposes`)
185
+ }
186
+ }
187
+ return allow()
188
+ }
189
+
190
+ case 'auditLog':
191
+ checked.push('auditLog')
192
+ return granted(scope.auditLog) ? allow() : deny('scope does not grant auditLog')
193
+
194
+ case 'credentials':
195
+ checked.push('credentials')
196
+ return granted(scope.credentials)
197
+ ? allow()
198
+ : deny('scope does not grant credentials')
199
+
200
+ default:
201
+ return deny(`unknown action type '${action.type}'`)
202
+ }
203
+ }
204
+
84
205
  /**
85
206
  * Compute a hex SHA-256 of the JCS-canonical scope.
86
207
  * This hash is stored on-chain so the relay can verify scope integrity.