@scopebond/hook 0.8.0 → 0.9.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.
Files changed (102) hide show
  1. package/README.md +210 -0
  2. package/dist/budget-load.d.ts +68 -0
  3. package/dist/budget-load.d.ts.map +1 -0
  4. package/dist/budget-load.js +164 -0
  5. package/dist/budget-load.js.map +1 -0
  6. package/dist/capabilities.d.ts +103 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/capabilities.js +212 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/classify.d.ts +17 -0
  11. package/dist/classify.d.ts.map +1 -0
  12. package/dist/classify.js +78 -0
  13. package/dist/classify.js.map +1 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +546 -16
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud.d.ts +8 -0
  18. package/dist/cloud.d.ts.map +1 -1
  19. package/dist/cloud.js.map +1 -1
  20. package/dist/dispatch-cli.d.ts +2 -0
  21. package/dist/dispatch-cli.d.ts.map +1 -0
  22. package/dist/dispatch-cli.js +128 -0
  23. package/dist/dispatch-cli.js.map +1 -0
  24. package/dist/explain.d.ts.map +1 -1
  25. package/dist/explain.js +2 -1
  26. package/dist/explain.js.map +1 -1
  27. package/dist/group.d.ts +12 -0
  28. package/dist/group.d.ts.map +1 -0
  29. package/dist/group.js +55 -0
  30. package/dist/group.js.map +1 -0
  31. package/dist/index.d.ts +25 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +17 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.d.ts +10 -0
  36. package/dist/install.d.ts.map +1 -1
  37. package/dist/install.js +56 -0
  38. package/dist/install.js.map +1 -1
  39. package/dist/map.d.ts +4 -0
  40. package/dist/map.d.ts.map +1 -1
  41. package/dist/map.js +29 -1
  42. package/dist/map.js.map +1 -1
  43. package/dist/obs-emitter.d.ts +132 -0
  44. package/dist/obs-emitter.d.ts.map +1 -0
  45. package/dist/obs-emitter.js +415 -0
  46. package/dist/obs-emitter.js.map +1 -0
  47. package/dist/obs-store.d.ts +160 -0
  48. package/dist/obs-store.d.ts.map +1 -0
  49. package/dist/obs-store.js +367 -0
  50. package/dist/obs-store.js.map +1 -0
  51. package/dist/obs-upload.d.ts +24 -0
  52. package/dist/obs-upload.d.ts.map +1 -0
  53. package/dist/obs-upload.js +231 -0
  54. package/dist/obs-upload.js.map +1 -0
  55. package/dist/observation.d.ts +232 -0
  56. package/dist/observation.d.ts.map +1 -0
  57. package/dist/observation.js +308 -0
  58. package/dist/observation.js.map +1 -0
  59. package/dist/paths.d.ts +15 -0
  60. package/dist/paths.d.ts.map +1 -0
  61. package/dist/paths.js +79 -0
  62. package/dist/paths.js.map +1 -0
  63. package/dist/policy-load.d.ts +60 -0
  64. package/dist/policy-load.d.ts.map +1 -0
  65. package/dist/policy-load.js +151 -0
  66. package/dist/policy-load.js.map +1 -0
  67. package/dist/proof.d.ts +30 -0
  68. package/dist/proof.d.ts.map +1 -0
  69. package/dist/proof.js +186 -0
  70. package/dist/proof.js.map +1 -0
  71. package/dist/rules.d.ts +20 -0
  72. package/dist/rules.d.ts.map +1 -1
  73. package/dist/rules.js +50 -0
  74. package/dist/rules.js.map +1 -1
  75. package/dist/runtime.d.ts +18 -1
  76. package/dist/runtime.d.ts.map +1 -1
  77. package/dist/runtime.js +74 -10
  78. package/dist/runtime.js.map +1 -1
  79. package/dist/scan.d.ts +7 -0
  80. package/dist/scan.d.ts.map +1 -0
  81. package/dist/scan.js +38 -0
  82. package/dist/scan.js.map +1 -0
  83. package/dist/shell.d.ts.map +1 -1
  84. package/dist/shell.js +12 -3
  85. package/dist/shell.js.map +1 -1
  86. package/dist/sql-classify.d.ts +11 -0
  87. package/dist/sql-classify.d.ts.map +1 -0
  88. package/dist/sql-classify.js +251 -0
  89. package/dist/sql-classify.js.map +1 -0
  90. package/dist/typed-infra.d.ts +73 -0
  91. package/dist/typed-infra.d.ts.map +1 -0
  92. package/dist/typed-infra.js +758 -0
  93. package/dist/typed-infra.js.map +1 -0
  94. package/dist/typed-ops.d.ts +140 -0
  95. package/dist/typed-ops.d.ts.map +1 -0
  96. package/dist/typed-ops.js +549 -0
  97. package/dist/typed-ops.js.map +1 -0
  98. package/dist/vectors.d.ts +40 -0
  99. package/dist/vectors.d.ts.map +1 -0
  100. package/dist/vectors.js +219 -0
  101. package/dist/vectors.js.map +1 -0
  102. package/package.json +4 -4
package/README.md CHANGED
@@ -131,6 +131,53 @@ before-edit hook), so an out-of-policy edit there is signed and flagged, not blo
131
131
  and the message says so rather than claiming otherwise. For edits that must be stopped
132
132
  before they land, make `@scopebond/github-action` a required check on pull requests.
133
133
 
134
+ Codex has no native file-read event: reads it makes through shell commands are derived
135
+ from the command (`cat .env`) and checked, but a read that never goes through a shell
136
+ is not seen. Run `capabilities` (below) for the exact per-cell picture.
137
+
138
+ ### What this hook can honestly claim: `capabilities`
139
+
140
+ ```
141
+ npx @scopebond/hook capabilities # the manifest, per agent host / action / phase
142
+ npx @scopebond/hook capabilities --prove # run the safe fixtures in temp directories
143
+ npx @scopebond/hook capabilities --prove --save # and record the result beside the policy
144
+ npx @scopebond/hook capabilities --json
145
+ ```
146
+
147
+ Each cell is one connector version, agent host (Claude terminal / desktop, Codex CLI /
148
+ desktop, Cursor), action type and phase (before or after the action), in one of these
149
+ states:
150
+
151
+ | State | Meaning |
152
+ |---|---|
153
+ | `unsupported` | no hook for it, or it is known to escape interception (nested tool wrappers) |
154
+ | `inactive` | the hook could cover it but that agent is not configured |
155
+ | `configured_unverified` | configured; never proven, or proven only by a local fixture |
156
+ | `degraded` | the current fixtures failed |
157
+ | `verified_reporting` | a current proof from a real agent run, acknowledged by Cloud |
158
+
159
+ `--prove` checks, for every supported cell, that a safe action is allowed and signed, a
160
+ violating one is denied at the hook, every receipt verifies against the countersigning
161
+ key, and every receipt of a tool call carries one action group. It uses temporary
162
+ directories only and never reads or changes agent settings, policy or keys. A local
163
+ fixture proves the adapter and policy on this machine; it cannot prove that Codex
164
+ desktop or Cursor delivers the event, nor that Cloud received a receipt, so it never
165
+ produces `verified_reporting`. Actions the agent only reports after they happened
166
+ (Cursor's `afterFileEdit`) and actions the default policy only observes (fetches, MCP
167
+ calls) are proven with a known successful fixture, labelled observation-only; no
168
+ "denied" result is claimed for them.
169
+
170
+ **Action groups.** A shell call can produce several receipts (the command, each file it
171
+ reads or writes, every pushed ref). They now carry `action_group`, `action_group_size`
172
+ and `action_group_seq` inside the signed intent's `params`, so they can be linked and
173
+ counted once without guessing; the id comes from the agent's own tool-call id when it
174
+ sends one.
175
+
176
+ **Workspace roots (optional).** `rules.json` accepts `"allowed_roots": ["."]`. With it,
177
+ a write whose physical target (symlinks and junctions followed; rename and link
178
+ destinations included) is outside the roots, or cannot be resolved, is denied. Without
179
+ it nothing changes. Run `rules apply` after adding it.
180
+
134
181
  ## Connect it to your workspace (optional)
135
182
 
136
183
  To see the receipts in your hosted Scopebond workspace, sign this computer in:
@@ -169,6 +216,169 @@ locally and retried if the workspace is unreachable. `npx @scopebond/hook flush`
169
216
  delivers anything still queued — run it on a session-end hook (and set
170
217
  `SCOPEBOND_HOOK_FLUSH_MS=0`) if you want zero per-call latency.
171
218
 
219
+ ### Session, health and action observations (opt-in)
220
+
221
+ Beyond receipts, the hook can send a second kind of signed record, an *observation*,
222
+ to a workspace that supports them. It is **off unless your workspace enrollment grants
223
+ `observations:write`**, and it needs the enrollment to give an installation id (the
224
+ enrollment's `gateway_id`, or an explicit `installation_id`) and an
225
+ `installation_generation`; without those the hook says so in `scopebond status` and
226
+ emits nothing. It never guesses a generation. It
227
+ never affects a decision: sending is best effort, bounded, and runs after the decision is
228
+ made, so an unreachable workspace, a missing route or a rate limit changes nothing about
229
+ what is allowed or denied.
230
+
231
+ Each observation is Ed25519-signed with the enrolled agent key over the domain
232
+ `scopebond:observation/v1` plus a newline and the canonical (RFC 8785) payload, and sent
233
+ in batches to `POST /v1/observations` as `{ version: "1.0", items: [{ payload, signature }] }`.
234
+ A separate keyed digest binds each tool-call observation to the request the hook actually
235
+ evaluated; the key stays on this machine and is never uploaded. Paths, commands, hosts and
236
+ session ids never leave the machine: they are reduced to keyed opaque ids or closed enums.
237
+
238
+ | Kind | Sent when | Hosts |
239
+ |---|---|---|
240
+ | `session` start / stop | Claude Code `SessionStart` / `SessionEnd` (stop reasons: completed, cancelled, unknown; sleep is inferred) | Claude Code |
241
+ | `health` heartbeat | every 60 seconds while a session is explicitly active, from one short helper per session that ends with the session | Claude Code |
242
+ | `health` queue | oldest pending receipt time and count, at most every five minutes while a backlog exists | Claude Code |
243
+ | `tool_intent` | each evaluated action, linked to its receipt | Claude Code, Codex, Cursor (shell, file, git push, MCP) |
244
+ | `tool_outcome` | Claude Code `PostToolUse` / `PostToolUseFailure`, echoing the intent's binding | Claude Code |
245
+ | `capability` proof | `scopebond capabilities --prove` (marked as a fixture run, never live) | all |
246
+ | `policy_ack` | `scopebond policy load <export.json>`, once an exported policy is loaded or refused | all |
247
+
248
+ With observations on, `capabilities --prove` signs its fixtures with this machine's own keys
249
+ (copied into the temporary directory; the originals are never changed), delivers the fixture
250
+ receipts to the workspace first, and only then sends each proof, naming those receipts by
251
+ `proof_digests`: the SHA-256 of `scopebond:source-receipt/v1` plus a newline plus the
252
+ canonical full signed receipt. Only receipts of the cell's own action type are named. If the
253
+ receipts cannot be delivered, no proof is sent. The fixture receipts are real receipts in
254
+ the workspace's log.
255
+
256
+ `scopebond policy load <export.json>` checks a policy exported from the workspace (its
257
+ policy hash must match the policy, its scope digest must match the export, agent and
258
+ environment, and the environment must be the one this machine is connected to) and says
259
+ what loading would replace; `--yes` writes it atomically as `policy.json` (the old one is
260
+ kept as `policy.previous.json`). It then acknowledges the load, or the refusal with a
261
+ reason, echoing the export's policy hash, policy id, policy version and scope digest
262
+ exactly. The export itself carries no signature, so get the file from your workspace.
263
+ `scopebond rules apply` recompiles `policy.json` from `rules.json` and would replace a loaded policy.
264
+
265
+ `scopebond budget load <export.json>` does the same for an action budget exported from the
266
+ workspace: it checks the document type and version, the policy digest (over the policy
267
+ without its acknowledgement), the scope digest, the environment, the validity window and the
268
+ fail-closed contract, and refuses a budget an independent installation cannot enforce (one
269
+ shared across installations). `--yes` writes it into `dispatch.json` as an acknowledged
270
+ budget for this agent (replacing an older workspace budget, never a newer one) and queues
271
+ the acknowledgement with the export id, budget id and version, and digests the workspace
272
+ expects. The export names the agent key it is for (`agent_kid`): a different key than this
273
+ machine's is refused (and acknowledged as rejected); an export whose key the workspace could not
274
+ state (null) loads with a warning that its identity could not be bound, and an export without
275
+ the field is refused. The budget's actor is that key. An enforced budget denies new dispatch
276
+ once its export has expired, until you load a new one.
277
+
278
+ When this machine is connected with `observations:write`, the dispatch boundary can also use
279
+ the workspace: an approval granted there for exactly the request is found and consumed at
280
+ dispatch with nothing to copy (the guard asks `GET /v1/monitoring/approvals/active` with the
281
+ request hash, action type and opaque target id; the denial message still prints the request
282
+ hash and target id to approve). Leaving `{ "cloud_approval_id": "<id>" }` in `approvals/`
283
+ still works and is used first. Only the consume approves: a lookup that finds nothing, fails
284
+ or finds another request approves nothing. A delegated session this machine does not know is resolved from the workspace
285
+ (scope entries are digests of `action_type NUL target-id` or `action_type NUL *`, over the
286
+ same opaque target id; the workspace's own `covers` answer for the action decides when it
287
+ gives one) and cached for 15 seconds. With approvals required for an action type in
288
+ `dispatch.json`, each typed operation also carries `approval_request_hash` (the hash the guard
289
+ consumes with) and a `resource_id` equal to the guard's target id, so a consumed approval can be
290
+ matched to its intent; the field is a claim and authorizes nothing. If the workspace cannot be reached and no valid local approval is
291
+ presented, an enforced action is denied. Targets reach the workspace only as keyed opaque ids.
292
+ Set `"cloud": false` in `dispatch.json` to keep everything local.
293
+
294
+ #### Typed operations: git, GitHub and package installs
295
+
296
+ A `tool_intent` carries one closed typed operation read from the request the hook is about to
297
+ allow: the raw shell command or the MCP tool input, never a model-written summary. It replaces the
298
+ generic shell operation for the same receipt when it can be described:
299
+
300
+ | Operation | From | What it carries |
301
+ |---|---|---|
302
+ | `git` commit | `git commit` | repository id, current branch as a keyed ref id with a protected flag, HEAD before the commit |
303
+ | `git` push, delete, mirror | `git push` (`--delete`, `:ref`, `--mirror`) | the same, plus `force`, the remote as a keyed id (a name and its URL give one id; credentials in a URL are dropped) and `resolution` |
304
+ | `github_resource` pr_create, release_create | `gh pr create`, `gh release create`, the GitHub MCP `create_pull_request` | keyed repository id and the base and head commits, or the release commit, read from the local clone; `required_check_policy_version` is `unbound` unless `SCOPEBOND_REQUIRED_CHECK_POLICY_VERSION` is set |
305
+ | `package` install, add, update | `npm`, `pnpm`, `yarn`, `pip`, `uv` with named packages | manager, the version pinned in `package.json`'s `packageManager` when it names that manager, and per package the name, an exact version only when the request pinned one, and the registry host only when the command named one |
306
+
307
+ Unknown facts stay unknown. An unresolved ref or remote is `resolution: "unresolved"`;
308
+ `integrity_status` is always `unknown` because nothing is verified before the install runs; lifecycle
309
+ scripts are `blocked` only for `--ignore-scripts`, `unknown` for npm, pnpm and yarn otherwise, and
310
+ `not_supported` for pip and uv. A command that installs whatever a lockfile or requirements file lists
311
+ (`npm ci`, `pnpm install`, `pip install -r`, `uv sync`) names no packages and stays a plain shell
312
+ operation; so does anything after a `cd` in the same command line, `git -C`, a pull request from a fork
313
+ or another repository, and `gh pr edit` or `gh pr merge`, whose pull request is named only by number and
314
+ has no head or base commit without a platform read-back this hook does not do (the capability manifest lists
315
+ `github.pr_change` and `deploy.run` as unsupported). Non-registry package sources are named `url:`,
316
+ `git:` or `local:` with credentials and queries dropped. These cells are observation-only and stay
317
+ `configured_unverified` after `capabilities --prove` (a fixture proves the derivation on this machine, not the host);
318
+ no proof is sent for them because they have no receipt of their own action type to name.
319
+
320
+ Reference sets refer to refs, remotes and repositories by these keyed ids. `scopebond observations id ref main`,
321
+ `observations id remote <url>`, `observations id ghrepo owner/name` and `observations id mcp <server> <tool>`
322
+ print the id this installation gives a value; it reads the local key and sends nothing.
323
+
324
+ #### Typed operations: network, Cloudflare and databases
325
+
326
+ The same rules apply: the operation is read from the raw command (or a Claude Code `WebFetch` input),
327
+ unknown facts stay unknown, and a command the reader cannot place fully stays a plain shell operation.
328
+
329
+ | Operation | From | What it carries |
330
+ |---|---|---|
331
+ | `network` | `WebFetch` (Claude Code), `curl`, `wget`, `Invoke-WebRequest`, `Invoke-RestMethod` and their aliases | scheme, lowercase IDNA host, effective port, method, and `read` (GET, HEAD, OPTIONS), `write` (DELETE, or a POST, PUT or PATCH with no body) or `upload` (a POST, PUT or PATCH with a body or file) |
332
+ | `cloudflare_resource` | `wrangler` (also through `npx`, `pnpm exec`, `npm exec`): `deploy`, `delete`, `pages deploy`, `pages project create/delete`, `d1 create/delete`, `r2 bucket create/delete`, `r2 bucket dev-url enable/disable`, `r2 object put/delete` | resource kind and verb, keyed account and resource ids, the `--env` as an environment class and keyed binding, and a SHA-256 of the directory a Pages deploy sends or the file an R2 put sends |
333
+ | `database` | `wrangler d1 execute` and `d1 migrations apply`, `psql` (`-c`, `-f`), `sqlite3` | provider, verb (read, insert, update, delete, delete_all, create, alter, drop, migrate), `predicate_class` bounded, all or not_applicable, a keyed database id and a keyed digest of the statement or of the local migration set |
334
+
335
+ What never leaves the machine: the URL path, query and credentials, headers and request bodies; SQL text,
336
+ table and column names, literals and rows; recipient details; file contents. The database digest is an HMAC under
337
+ the installation key, so another installation cannot reproduce it. The SQL classifier is deliberately
338
+ conservative: an `UPDATE` or `DELETE` counts as bounded only when a top-level AND-ed condition is selective (an OR,
339
+ a tautology such as `1=1`, `LIKE '%'` or `IS NOT NULL` alone counts as every row), and SQL it cannot place (dynamic
340
+ SQL, `CALL`, `VACUUM`, a `CTE` that writes, an unbalanced quote) produces no database operation at all, because the
341
+ closed schema has no unknown verb.
342
+
343
+ Known limits, said plainly: a redirect hop is never followed or bound (`redirect_binding` is always omitted, so a
344
+ redirected request is not qualified against the origin's approval); a request sent through a proxy,
345
+ `--resolve`, `--connect-to`, a config file or more than one URL is not described; IPv6 literals and non-HTTP
346
+ schemes are not described; `wrangler` has no DNS command, so `dns_record` changes are not seen; `d1 execute` records
347
+ the environment only from `--local` or `--env`, and `environment_class` is otherwise `unknown` (the hook cannot tell which database
348
+ is production); a `d1 migrations apply` digest covers the whole local migrations directory, not only the pending files;
349
+ account ids come from an inline `CLOUDFLARE_ACCOUNT_ID=`, the Wrangler config or this process's environment and are
350
+ `unbound` when none names one. The shell receipt of a command still carries the redacted command head the hook has
351
+ always recorded (a scrubbed prefix of up to 64 characters), which can include the start of an inline SQL statement.
352
+
353
+ Capability cells `network.request`, `cloudflare.resource` and `database.exec` are observation-only and stay
354
+ `configured_unverified` without a real-host proof. `browser.action`, `communication.send` and `visibility.change`
355
+ are registered as `unsupported` with their reasons: no host this hook supports sends an approved browser or
356
+ communications event (a browser or mail tool arrives as a generic MCP call with no origin, verb, destination or
357
+ attachment set), and no supported source reports a resource's visibility before and after.
358
+ `observations id net-dest <host> <port>`, `observations id cf <kind> <name>` and `observations id database <pg|sqlite> <key>`
359
+ print the keyed ids for writing reference sets.
360
+
361
+ **Optional enforcement.** `scopebond-hook rules protect-remote-database` (off by default; `rules.json` field
362
+ `protect_remote_database`) adds an enforce clause that denies, before the command runs, SQL against a remote
363
+ database (a `wrangler d1` command without `--local`, or `psql` to a host other than this machine) that drops a table,
364
+ deletes or updates every row, drops or renames inside an `ALTER`, or cannot be read. The hook cannot tell which
365
+ database is production, so every remote database is covered; `wrangler d1 execute` with neither `--local` nor `--remote`
366
+ is treated as remote because Wrangler's default has differed between versions. Everything else stays observation.
367
+
368
+ Codex and Cursor have no tested session or after-action mapping, so those kinds are not
369
+ sent for them. Nothing is sent from a sleeping host: a heartbeat gap is recorded as a stop
370
+ with reason `sleep`, and an idle session releases its lease with one final heartbeat.
371
+
372
+ Delivery follows the workspace's answers: only acknowledged items are removed, deferred
373
+ items are retried with the same id and sequence, and items the workspace refuses stay in a
374
+ local terminal-error queue shown by `scopebond observations status --refused` (an
375
+ unsupported version or a stale generation is never retried; a rate limit, a paused plan
376
+ (HTTP 402) and a refusal without an item id are handled as the workspace reports them). A workspace without the route
377
+ is marked unsupported and nothing more is queued until `scopebond observations retry`.
378
+ `scopebond observations wire` adds the Claude Code session and after-action hook entries
379
+ (`connect` does it automatically when the enrollment grants the scope); `unwire` removes
380
+ only those. `SCOPEBOND_OBSERVATIONS_HEARTBEAT=off` keeps everything except the heartbeat helper.
381
+
172
382
  ## How it works
173
383
 
174
384
  Each tool call is mapped to a normalized [Action Taxonomy](https://github.com/avouro-com/scopebond)
@@ -0,0 +1,68 @@
1
+ import { type ActionBudgetPolicy } from "@scopebond/gateway";
2
+ import { type PolicyAckInput, type PolicyLoadError } from "./observation.js";
3
+ export declare const BUDGET_EXPORT_TYPE = "scopebond:action-budget-export";
4
+ export interface BudgetExportFacts {
5
+ exportId: string;
6
+ budgetId: string;
7
+ budgetVersion: number;
8
+ policyDigest: string;
9
+ scopeDigest: string;
10
+ agentId: string;
11
+ /** The key the workspace says this agent signs with, or null when it could not say (no enrolled key). */
12
+ agentKid: string | null;
13
+ environmentId: string;
14
+ validUntil: number;
15
+ policy: {
16
+ operation_set: string[];
17
+ authority_scope: "installation" | "shared_gateway";
18
+ max_dispatch: number;
19
+ window_seconds: number;
20
+ mode: "monitor" | "enforce";
21
+ version: number;
22
+ };
23
+ }
24
+ export type BudgetInspection = {
25
+ ok: true;
26
+ facts: BudgetExportFacts;
27
+ }
28
+ /** `ack` is set when the export names enough to echo in a rejection; otherwise nothing is acknowledged. */
29
+ | {
30
+ ok: false;
31
+ error: PolicyLoadError | "expired";
32
+ message: string;
33
+ ack?: Omit<PolicyAckInput, "error">;
34
+ };
35
+ /** Accept the export itself, or the workspace's `{ version, export }` answer around it. */
36
+ export declare function unwrapBudgetExport(raw: unknown): Record<string, unknown> | null;
37
+ /** Check an export without reading or changing anything on disk. Pure. */
38
+ export declare function inspectBudgetExport(raw: unknown, context?: {
39
+ environmentId?: string;
40
+ now?: number;
41
+ }): BudgetInspection;
42
+ /** The budget policy this machine enforces for an export: the reviewed limits, acting as this installation's agent, valid until the export says. */
43
+ export declare function localBudgetOf(facts: BudgetExportFacts, agentKid: string, acknowledgedAt: string): ActionBudgetPolicy & {
44
+ source_export_id: string;
45
+ };
46
+ export type BudgetLoadOutcome = {
47
+ state: "loaded";
48
+ facts: BudgetExportFacts;
49
+ replaced: string[];
50
+ warnings: string[];
51
+ } | {
52
+ state: "would_load";
53
+ facts: BudgetExportFacts;
54
+ warnings: string[];
55
+ } | {
56
+ state: "rejected";
57
+ error: PolicyLoadError | "expired";
58
+ message: string;
59
+ ack?: Omit<PolicyAckInput, "error">;
60
+ };
61
+ /** Read an export file, check it, and (with `apply`) write it into `dispatch.json` as an acknowledged budget. */
62
+ export declare function loadBudgetExport(dir: string, file: string, options: {
63
+ apply: boolean;
64
+ agentKid: string;
65
+ environmentId?: string;
66
+ now?: number;
67
+ }): BudgetLoadOutcome;
68
+ //# sourceMappingURL=budget-load.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget-load.d.ts","sourceRoot":"","sources":["../src/budget-load.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAgB,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE3E,OAAO,EAAgB,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAG3F,eAAO,MAAM,kBAAkB,mCAAmC,CAAC;AAKnE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,yGAAyG;IACzG,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE;QAAE,aAAa,EAAE,MAAM,EAAE,CAAC;QAAC,eAAe,EAAE,cAAc,GAAG,gBAAgB,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,SAAS,GAAG,SAAS,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CACrL;AAED,MAAM,MAAM,gBAAgB,GACxB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,iBAAiB,CAAA;CAAE;AACxC,2GAA2G;GACzG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,eAAe,GAAG,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC;AAE5G,2FAA2F;AAC3F,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAI/E;AAED,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,gBAAgB,CAyD1H;AAED,oJAAoJ;AACpJ,wBAAgB,aAAa,CAAC,KAAK,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,kBAAkB,GAAG;IAAE,gBAAgB,EAAE,MAAM,CAAA;CAAE,CAUnJ;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,KAAK,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,iBAAiB,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GACrF;IAAE,KAAK,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,iBAAiB,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GACrE;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,eAAe,GAAG,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC;AAEpH,iHAAiH;AACjH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,iBAAiB,CAmClK"}
@@ -0,0 +1,164 @@
1
+ // `scopebond budget load <export.json>`: load a reviewed action budget exported from a workspace,
2
+ // as the budget this machine enforces, and say honestly what happened.
3
+ //
4
+ // An export is a reviewed, approved policy in a file. It is pending in the workspace until a signed
5
+ // acknowledgement echoes exactly what the export said: its export id, the budget id and version, the
6
+ // policy digest and the scope digest. This module checks an export and, when asked, writes it into
7
+ // `dispatch.json` as an acknowledged budget policy; the caller then queues the `policy_ack`.
8
+ //
9
+ // What is checked, and what is not:
10
+ // - the document type and version, and the fail-closed contract it carries (an export that would
11
+ // permit unlimited dispatch on failure is refused);
12
+ // - the policy digest: SHA-256 of the canonical policy with its acknowledgement removed, which is
13
+ // what the workspace hashed when the budget was drafted;
14
+ // - the scope digest, the environment (when this machine is connected), the validity window, and
15
+ // that the policy is one an independent installation can enforce (a limit shared across
16
+ // installations needs a shared in-path gateway, which a hook is not).
17
+ // An export carries no signature of its own: get the file from your workspace, over its own page or
18
+ // API. Loading replaces an older workspace budget for the same agent; it never replaces a newer one.
19
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { randomBytes } from "node:crypto";
22
+ import { budgetDigest } from "@scopebond/gateway";
23
+ import { DISPATCH_FILE, readDispatchFile } from "@scopebond/gateway/node";
24
+ import { digestPolicy } from "./observation.js";
25
+ import { policyScopeDigest, MAX_EXPORT_BYTES } from "./policy-load.js";
26
+ export const BUDGET_EXPORT_TYPE = "scopebond:action-budget-export";
27
+ const HEX64 = /^[0-9a-f]{64}$/;
28
+ const OPAQUE = /^[\x21-\x7e]{1,200}$/;
29
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
+ /** Accept the export itself, or the workspace's `{ version, export }` answer around it. */
31
+ export function unwrapBudgetExport(raw) {
32
+ if (!isObject(raw))
33
+ return null;
34
+ if (raw.type === undefined && isObject(raw.export))
35
+ return raw.export;
36
+ return raw;
37
+ }
38
+ /** Check an export without reading or changing anything on disk. Pure. */
39
+ export function inspectBudgetExport(raw, context = {}) {
40
+ const exp = unwrapBudgetExport(raw);
41
+ if (!exp || exp.type !== BUDGET_EXPORT_TYPE)
42
+ return { ok: false, error: "schema_invalid", message: "this is not a Scopebond action budget export" };
43
+ if (exp.version !== 1)
44
+ return { ok: false, error: "unsupported", message: `unsupported export version ${String(exp.version)}` };
45
+ const { export_id: exportId, budget_id: budgetId, budget_version: budgetVersion, agent_id: agentId, environment_id: environmentId, valid_until: validUntil } = exp;
46
+ const policy = exp.policy;
47
+ const agentKid = exp.agent_kid;
48
+ // `agent_kid` is always present in an export (a key id, or null when the workspace has no enrolled key for the agent); an export without it predates identity binding.
49
+ if (agentKid === undefined || (agentKid !== null && (typeof agentKid !== "string" || !OPAQUE.test(agentKid)))) {
50
+ return { ok: false, error: "schema_invalid", message: "the export does not say which agent key it is for (agent_kid is missing or malformed); export the budget again from the workspace" };
51
+ }
52
+ if (typeof exportId !== "string" || !OPAQUE.test(exportId) || typeof budgetId !== "string" || !OPAQUE.test(budgetId) || !Number.isInteger(budgetVersion) || budgetVersion < 1
53
+ || budgetVersion > 2_147_483_647 || typeof agentId !== "string" || !OPAQUE.test(agentId) || typeof environmentId !== "string" || !OPAQUE.test(environmentId)
54
+ || typeof validUntil !== "number" || !Number.isFinite(validUntil) || !isObject(policy) || typeof exp.policy_digest !== "string" || !HEX64.test(exp.policy_digest)
55
+ || typeof exp.scope_digest !== "string" || !HEX64.test(exp.scope_digest)) {
56
+ return { ok: false, error: "schema_invalid", message: "the export lacks a budget id, version, policy, digest or validity window, or they are malformed" };
57
+ }
58
+ // Echoed exactly as the export states them, so a rejection still names the export.
59
+ const echo = { exportId, policyId: budgetId, policyVersion: budgetVersion, policyDigest: exp.policy_digest, scopeDigest: exp.scope_digest };
60
+ const ops = policy.operation_set;
61
+ if (!Array.isArray(ops) || ops.length < 1 || ops.length > 100 || !ops.every((o) => typeof o === "string" && OPAQUE.test(o))
62
+ || (policy.authority_scope !== "installation" && policy.authority_scope !== "shared_gateway") || (policy.mode !== "monitor" && policy.mode !== "enforce")
63
+ || !Number.isInteger(policy.max_dispatch) || policy.max_dispatch < 1 || policy.max_dispatch > 1_000_000_000
64
+ || !Number.isInteger(policy.window_seconds) || policy.window_seconds < 1 || policy.window_seconds > 366 * 86_400
65
+ || policy.version !== budgetVersion) {
66
+ return { ok: false, error: "schema_invalid", message: "the export's budget policy is malformed", ack: echo };
67
+ }
68
+ if (digestPolicy({ ...policy, acknowledged: null }) !== exp.policy_digest) {
69
+ return { ok: false, error: "signature_invalid", message: "the policy does not match the export's policy digest; the file was changed or damaged", ack: echo };
70
+ }
71
+ if (policyScopeDigest({ export_id: exportId, agent_id: agentId, environment_id: environmentId }) !== exp.scope_digest) {
72
+ return { ok: false, error: "scope_mismatch", message: "the export's scope digest does not match its export, agent and environment", ack: echo };
73
+ }
74
+ if (context.environmentId !== undefined && context.environmentId !== environmentId) {
75
+ return { ok: false, error: "scope_mismatch", message: "this export is for a different environment than this machine is connected to", ack: echo };
76
+ }
77
+ if (policy.acknowledged === null || policy.acknowledged === undefined) {
78
+ return { ok: false, error: "schema_invalid", message: "the budget was not approved in the workspace (no acknowledgement); only an approved budget can be loaded", ack: echo };
79
+ }
80
+ const enforcement = isObject(exp.enforcement) ? exp.enforcement : null;
81
+ const failClosed = enforcement && isObject(enforcement.fail_closed) ? enforcement.fail_closed : null;
82
+ if (!failClosed || failClosed.unlimited_dispatch_on_failure !== false) {
83
+ return { ok: false, error: "unsupported", message: "the export does not state the fail-closed contract (no unlimited dispatch on failure); it is refused", ack: echo };
84
+ }
85
+ if (policy.mode === "enforce" && (policy.authority_scope !== "installation" || enforcement?.eligible !== true)) {
86
+ return { ok: false, error: "unsupported", message: "this budget cannot be enforced by an independent installation (a limit shared across installations needs a shared in-path gateway)", ack: echo };
87
+ }
88
+ if (validUntil <= (context.now ?? Date.now())) {
89
+ return { ok: false, error: "expired", message: "this export is past its validity window; export the budget again from the workspace" };
90
+ }
91
+ return {
92
+ ok: true,
93
+ facts: {
94
+ agentKid: agentKid, exportId, budgetId, budgetVersion: budgetVersion, policyDigest: exp.policy_digest, scopeDigest: exp.scope_digest, agentId, environmentId, validUntil,
95
+ policy: policy,
96
+ },
97
+ };
98
+ }
99
+ /** The budget policy this machine enforces for an export: the reviewed limits, acting as this installation's agent, valid until the export says. */
100
+ export function localBudgetOf(facts, agentKid, acknowledgedAt) {
101
+ const policy = {
102
+ // The actor is the export's agent key; a null one (unverifiable identity) falls back to this installation's key, which is the only agent this machine dispatches as.
103
+ budget_id: facts.budgetId, actor: facts.agentKid ?? agentKid, operations: [...facts.policy.operation_set], authority_scope: facts.policy.authority_scope, max: facts.policy.max_dispatch,
104
+ window_seconds: facts.policy.window_seconds, mode: facts.policy.mode, version: facts.budgetVersion, expires_at: new Date(facts.validUntil).toISOString(),
105
+ acknowledgement: null, source_export_id: facts.exportId,
106
+ };
107
+ // Loading it is the acknowledgement: the person who ran `budget load --yes` accepts this exact policy.
108
+ policy.acknowledgement = { digest: budgetDigest(policy), acknowledged_at: acknowledgedAt };
109
+ return policy;
110
+ }
111
+ /** Read an export file, check it, and (with `apply`) write it into `dispatch.json` as an acknowledged budget. */
112
+ export function loadBudgetExport(dir, file, options) {
113
+ let raw;
114
+ try {
115
+ if (statSync(file).size > MAX_EXPORT_BYTES)
116
+ return { state: "rejected", error: "schema_invalid", message: "the export file is larger than 1 MiB" };
117
+ raw = JSON.parse(readFileSync(file, "utf8"));
118
+ }
119
+ catch (error) {
120
+ return { state: "rejected", error: "schema_invalid", message: `the export could not be read as JSON (${error.name})` };
121
+ }
122
+ const inspected = inspectBudgetExport(raw, { environmentId: options.environmentId, now: options.now });
123
+ if (!inspected.ok)
124
+ return { state: "rejected", error: inspected.error, message: inspected.message, ...(inspected.ack ? { ack: inspected.ack } : {}) };
125
+ const { facts } = inspected;
126
+ const ack = { exportId: facts.exportId, policyId: facts.budgetId, policyVersion: facts.budgetVersion, policyDigest: facts.policyDigest, scopeDigest: facts.scopeDigest };
127
+ if (!options.agentKid)
128
+ return { state: "rejected", error: "unsupported", message: "this machine has no agent key to act as; run init first", ack };
129
+ if (facts.agentKid !== null && facts.agentKid !== options.agentKid) {
130
+ return { state: "rejected", error: "scope_mismatch", message: `this export is for the agent key ${facts.agentKid}, but this machine's agent key is ${options.agentKid}; it was exported for a different agent or installation`, ack };
131
+ }
132
+ const warnings = facts.agentKid === null ? ["the workspace has no enrolled key for this agent, so the export could not be bound to this machine's agent identity; it is loaded for this machine's key on your say-so"] : [];
133
+ const current = readDispatchFile(dir) ?? {};
134
+ const existing = (current.budgets ?? []);
135
+ // Workspace budgets are per agent and versioned; a same or newer version is never replaced by an older export.
136
+ const workspace = existing.filter((b) => b.source_export_id !== undefined && b.actor === options.agentKid);
137
+ const newer = workspace.find((b) => b.budget_id !== facts.budgetId && b.version >= facts.budgetVersion);
138
+ if (newer)
139
+ return { state: "rejected", error: "unsupported", message: `a same or newer workspace budget (version ${newer.version}) is already loaded; an older export cannot replace it`, ack };
140
+ const same = workspace.find((b) => b.budget_id === facts.budgetId);
141
+ if (same && same.version > facts.budgetVersion)
142
+ return { state: "rejected", error: "unsupported", message: `version ${same.version} of this budget is already loaded`, ack };
143
+ if (!options.apply)
144
+ return { state: "would_load", facts, warnings };
145
+ mkdirSync(dir, { recursive: true });
146
+ const replaced = workspace.filter((b) => b.budget_id !== facts.budgetId).map((b) => b.budget_id);
147
+ const kept = existing.filter((b) => !workspace.includes(b));
148
+ const next = { ...current, budgets: [...kept, localBudgetOf(facts, options.agentKid, new Date(options.now ?? Date.now()).toISOString())] };
149
+ const target = join(dir, DISPATCH_FILE);
150
+ const temp = `${target}.${randomBytes(6).toString("hex")}.tmp`;
151
+ try {
152
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
153
+ renameSync(temp, target);
154
+ }
155
+ catch (error) {
156
+ try {
157
+ rmSync(temp, { force: true });
158
+ }
159
+ catch { /* nothing to remove */ }
160
+ throw error;
161
+ }
162
+ return { state: "loaded", facts, replaced, warnings };
163
+ }
164
+ //# sourceMappingURL=budget-load.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget-load.js","sourceRoot":"","sources":["../src/budget-load.ts"],"names":[],"mappings":"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","sourcesContent":["// `scopebond budget load <export.json>`: load a reviewed action budget exported from a workspace,\n// as the budget this machine enforces, and say honestly what happened.\n//\n// An export is a reviewed, approved policy in a file. It is pending in the workspace until a signed\n// acknowledgement echoes exactly what the export said: its export id, the budget id and version, the\n// policy digest and the scope digest. This module checks an export and, when asked, writes it into\n// `dispatch.json` as an acknowledged budget policy; the caller then queues the `policy_ack`.\n//\n// What is checked, and what is not:\n// - the document type and version, and the fail-closed contract it carries (an export that would\n// permit unlimited dispatch on failure is refused);\n// - the policy digest: SHA-256 of the canonical policy with its acknowledgement removed, which is\n// what the workspace hashed when the budget was drafted;\n// - the scope digest, the environment (when this machine is connected), the validity window, and\n// that the policy is one an independent installation can enforce (a limit shared across\n// installations needs a shared in-path gateway, which a hook is not).\n// An export carries no signature of its own: get the file from your workspace, over its own page or\n// API. Loading replaces an older workspace budget for the same agent; it never replaces a newer one.\n\nimport { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { randomBytes } from \"node:crypto\";\nimport { budgetDigest, type ActionBudgetPolicy } from \"@scopebond/gateway\";\nimport { DISPATCH_FILE, readDispatchFile } from \"@scopebond/gateway/node\";\nimport { digestPolicy, type PolicyAckInput, type PolicyLoadError } from \"./observation.js\";\nimport { policyScopeDigest, MAX_EXPORT_BYTES } from \"./policy-load.js\";\n\nexport const BUDGET_EXPORT_TYPE = \"scopebond:action-budget-export\";\nconst HEX64 = /^[0-9a-f]{64}$/;\nconst OPAQUE = /^[\\x21-\\x7e]{1,200}$/;\nconst isObject = (v: unknown): v is Record<string, unknown> => typeof v === \"object\" && v !== null && !Array.isArray(v);\n\nexport interface BudgetExportFacts {\n exportId: string;\n budgetId: string;\n budgetVersion: number;\n policyDigest: string;\n scopeDigest: string;\n agentId: string;\n /** The key the workspace says this agent signs with, or null when it could not say (no enrolled key). */\n agentKid: string | null;\n environmentId: string;\n validUntil: number;\n policy: { operation_set: string[]; authority_scope: \"installation\" | \"shared_gateway\"; max_dispatch: number; window_seconds: number; mode: \"monitor\" | \"enforce\"; version: number };\n}\n\nexport type BudgetInspection =\n | { ok: true; facts: BudgetExportFacts }\n /** `ack` is set when the export names enough to echo in a rejection; otherwise nothing is acknowledged. */\n | { ok: false; error: PolicyLoadError | \"expired\"; message: string; ack?: Omit<PolicyAckInput, \"error\"> };\n\n/** Accept the export itself, or the workspace's `{ version, export }` answer around it. */\nexport function unwrapBudgetExport(raw: unknown): Record<string, unknown> | null {\n if (!isObject(raw)) return null;\n if (raw.type === undefined && isObject(raw.export)) return raw.export;\n return raw;\n}\n\n/** Check an export without reading or changing anything on disk. Pure. */\nexport function inspectBudgetExport(raw: unknown, context: { environmentId?: string; now?: number } = {}): BudgetInspection {\n const exp = unwrapBudgetExport(raw);\n if (!exp || exp.type !== BUDGET_EXPORT_TYPE) return { ok: false, error: \"schema_invalid\", message: \"this is not a Scopebond action budget export\" };\n if (exp.version !== 1) return { ok: false, error: \"unsupported\", message: `unsupported export version ${String(exp.version)}` };\n const { export_id: exportId, budget_id: budgetId, budget_version: budgetVersion, agent_id: agentId, environment_id: environmentId, valid_until: validUntil } = exp;\n const policy = exp.policy;\n const agentKid = exp.agent_kid;\n // `agent_kid` is always present in an export (a key id, or null when the workspace has no enrolled key for the agent); an export without it predates identity binding.\n if (agentKid === undefined || (agentKid !== null && (typeof agentKid !== \"string\" || !OPAQUE.test(agentKid)))) {\n return { ok: false, error: \"schema_invalid\", message: \"the export does not say which agent key it is for (agent_kid is missing or malformed); export the budget again from the workspace\" };\n }\n if (typeof exportId !== \"string\" || !OPAQUE.test(exportId) || typeof budgetId !== \"string\" || !OPAQUE.test(budgetId) || !Number.isInteger(budgetVersion) || (budgetVersion as number) < 1\n || (budgetVersion as number) > 2_147_483_647 || typeof agentId !== \"string\" || !OPAQUE.test(agentId) || typeof environmentId !== \"string\" || !OPAQUE.test(environmentId)\n || typeof validUntil !== \"number\" || !Number.isFinite(validUntil) || !isObject(policy) || typeof exp.policy_digest !== \"string\" || !HEX64.test(exp.policy_digest)\n || typeof exp.scope_digest !== \"string\" || !HEX64.test(exp.scope_digest)) {\n return { ok: false, error: \"schema_invalid\", message: \"the export lacks a budget id, version, policy, digest or validity window, or they are malformed\" };\n }\n // Echoed exactly as the export states them, so a rejection still names the export.\n const echo = { exportId, policyId: budgetId, policyVersion: budgetVersion as number, policyDigest: exp.policy_digest, scopeDigest: exp.scope_digest };\n const ops = policy.operation_set;\n if (!Array.isArray(ops) || ops.length < 1 || ops.length > 100 || !ops.every((o) => typeof o === \"string\" && OPAQUE.test(o))\n || (policy.authority_scope !== \"installation\" && policy.authority_scope !== \"shared_gateway\") || (policy.mode !== \"monitor\" && policy.mode !== \"enforce\")\n || !Number.isInteger(policy.max_dispatch) || (policy.max_dispatch as number) < 1 || (policy.max_dispatch as number) > 1_000_000_000\n || !Number.isInteger(policy.window_seconds) || (policy.window_seconds as number) < 1 || (policy.window_seconds as number) > 366 * 86_400\n || policy.version !== budgetVersion) {\n return { ok: false, error: \"schema_invalid\", message: \"the export's budget policy is malformed\", ack: echo };\n }\n if (digestPolicy({ ...policy, acknowledged: null }) !== exp.policy_digest) {\n return { ok: false, error: \"signature_invalid\", message: \"the policy does not match the export's policy digest; the file was changed or damaged\", ack: echo };\n }\n if (policyScopeDigest({ export_id: exportId, agent_id: agentId, environment_id: environmentId }) !== exp.scope_digest) {\n return { ok: false, error: \"scope_mismatch\", message: \"the export's scope digest does not match its export, agent and environment\", ack: echo };\n }\n if (context.environmentId !== undefined && context.environmentId !== environmentId) {\n return { ok: false, error: \"scope_mismatch\", message: \"this export is for a different environment than this machine is connected to\", ack: echo };\n }\n if (policy.acknowledged === null || policy.acknowledged === undefined) {\n return { ok: false, error: \"schema_invalid\", message: \"the budget was not approved in the workspace (no acknowledgement); only an approved budget can be loaded\", ack: echo };\n }\n const enforcement = isObject(exp.enforcement) ? exp.enforcement : null;\n const failClosed = enforcement && isObject(enforcement.fail_closed) ? enforcement.fail_closed : null;\n if (!failClosed || failClosed.unlimited_dispatch_on_failure !== false) {\n return { ok: false, error: \"unsupported\", message: \"the export does not state the fail-closed contract (no unlimited dispatch on failure); it is refused\", ack: echo };\n }\n if (policy.mode === \"enforce\" && (policy.authority_scope !== \"installation\" || enforcement?.eligible !== true)) {\n return { ok: false, error: \"unsupported\", message: \"this budget cannot be enforced by an independent installation (a limit shared across installations needs a shared in-path gateway)\", ack: echo };\n }\n if (validUntil <= (context.now ?? Date.now())) {\n return { ok: false, error: \"expired\", message: \"this export is past its validity window; export the budget again from the workspace\" };\n }\n return {\n ok: true,\n facts: {\n agentKid: agentKid as string | null, exportId, budgetId, budgetVersion: budgetVersion as number, policyDigest: exp.policy_digest, scopeDigest: exp.scope_digest, agentId, environmentId, validUntil,\n policy: policy as unknown as BudgetExportFacts[\"policy\"],\n },\n };\n}\n\n/** The budget policy this machine enforces for an export: the reviewed limits, acting as this installation's agent, valid until the export says. */\nexport function localBudgetOf(facts: BudgetExportFacts, agentKid: string, acknowledgedAt: string): ActionBudgetPolicy & { source_export_id: string } {\n const policy: ActionBudgetPolicy & { source_export_id: string } = {\n // The actor is the export's agent key; a null one (unverifiable identity) falls back to this installation's key, which is the only agent this machine dispatches as.\n budget_id: facts.budgetId, actor: facts.agentKid ?? agentKid, operations: [...facts.policy.operation_set], authority_scope: facts.policy.authority_scope, max: facts.policy.max_dispatch,\n window_seconds: facts.policy.window_seconds, mode: facts.policy.mode, version: facts.budgetVersion, expires_at: new Date(facts.validUntil).toISOString(),\n acknowledgement: null, source_export_id: facts.exportId,\n };\n // Loading it is the acknowledgement: the person who ran `budget load --yes` accepts this exact policy.\n policy.acknowledgement = { digest: budgetDigest(policy), acknowledged_at: acknowledgedAt };\n return policy;\n}\n\nexport type BudgetLoadOutcome =\n | { state: \"loaded\"; facts: BudgetExportFacts; replaced: string[]; warnings: string[] }\n | { state: \"would_load\"; facts: BudgetExportFacts; warnings: string[] }\n | { state: \"rejected\"; error: PolicyLoadError | \"expired\"; message: string; ack?: Omit<PolicyAckInput, \"error\"> };\n\n/** Read an export file, check it, and (with `apply`) write it into `dispatch.json` as an acknowledged budget. */\nexport function loadBudgetExport(dir: string, file: string, options: { apply: boolean; agentKid: string; environmentId?: string; now?: number }): BudgetLoadOutcome {\n let raw: unknown;\n try {\n if (statSync(file).size > MAX_EXPORT_BYTES) return { state: \"rejected\", error: \"schema_invalid\", message: \"the export file is larger than 1 MiB\" };\n raw = JSON.parse(readFileSync(file, \"utf8\"));\n } catch (error) {\n return { state: \"rejected\", error: \"schema_invalid\", message: `the export could not be read as JSON (${(error as Error).name})` };\n }\n const inspected = inspectBudgetExport(raw, { environmentId: options.environmentId, now: options.now });\n if (!inspected.ok) return { state: \"rejected\", error: inspected.error, message: inspected.message, ...(inspected.ack ? { ack: inspected.ack } : {}) };\n const { facts } = inspected;\n const ack = { exportId: facts.exportId, policyId: facts.budgetId, policyVersion: facts.budgetVersion, policyDigest: facts.policyDigest, scopeDigest: facts.scopeDigest };\n if (!options.agentKid) return { state: \"rejected\", error: \"unsupported\", message: \"this machine has no agent key to act as; run init first\", ack };\n if (facts.agentKid !== null && facts.agentKid !== options.agentKid) {\n return { state: \"rejected\", error: \"scope_mismatch\", message: `this export is for the agent key ${facts.agentKid}, but this machine's agent key is ${options.agentKid}; it was exported for a different agent or installation`, ack };\n }\n const warnings = facts.agentKid === null ? [\"the workspace has no enrolled key for this agent, so the export could not be bound to this machine's agent identity; it is loaded for this machine's key on your say-so\"] : [];\n const current = readDispatchFile(dir) ?? {};\n const existing = (current.budgets ?? []) as Array<ActionBudgetPolicy & { source_export_id?: string }>;\n // Workspace budgets are per agent and versioned; a same or newer version is never replaced by an older export.\n const workspace = existing.filter((b) => b.source_export_id !== undefined && b.actor === options.agentKid);\n const newer = workspace.find((b) => b.budget_id !== facts.budgetId && b.version >= facts.budgetVersion);\n if (newer) return { state: \"rejected\", error: \"unsupported\", message: `a same or newer workspace budget (version ${newer.version}) is already loaded; an older export cannot replace it`, ack };\n const same = workspace.find((b) => b.budget_id === facts.budgetId);\n if (same && same.version > facts.budgetVersion) return { state: \"rejected\", error: \"unsupported\", message: `version ${same.version} of this budget is already loaded`, ack };\n if (!options.apply) return { state: \"would_load\", facts, warnings };\n mkdirSync(dir, { recursive: true });\n const replaced = workspace.filter((b) => b.budget_id !== facts.budgetId).map((b) => b.budget_id);\n const kept = existing.filter((b) => !workspace.includes(b));\n const next = { ...current, budgets: [...kept, localBudgetOf(facts, options.agentKid, new Date(options.now ?? Date.now()).toISOString())] };\n const target = join(dir, DISPATCH_FILE);\n const temp = `${target}.${randomBytes(6).toString(\"hex\")}.tmp`;\n try { writeFileSync(temp, `${JSON.stringify(next, null, 2)}\\n`, { mode: 0o600 }); renameSync(temp, target); }\n catch (error) { try { rmSync(temp, { force: true }); } catch { /* nothing to remove */ } throw error; }\n return { state: \"loaded\", facts, replaced, warnings };\n}\n\n"]}
@@ -0,0 +1,103 @@
1
+ import { type Vector, type VectorAgent } from "./vectors.js";
2
+ export type CapabilityState = "unsupported" | "configured_unverified" | "verified_reporting" | "degraded" | "inactive" | "retired";
3
+ export type HostVariant = "claude_terminal" | "claude_desktop" | "codex_cli" | "codex_desktop" | "cursor";
4
+ export type EventPhase = "pre_action" | "after_action";
5
+ export type Adapter = VectorAgent;
6
+ /** The outcome of running a cell's fixtures. */
7
+ export interface ProofRecord {
8
+ cell: string;
9
+ ran_at: string;
10
+ adapter_version: string;
11
+ test_vector_digest: string;
12
+ /** `fixture`: local run against temp state. `live_harness`: recorded from a real host. */
13
+ origin: "fixture" | "live_harness";
14
+ /** A benign action was allowed and produced a countersigned receipt. */
15
+ safe_allow: boolean;
16
+ /** A violating action was denied at the claimed boundary, or `not_applicable`. */
17
+ safe_deny: boolean | "not_applicable";
18
+ /** Every receipt from the fixtures verified against the countersigning key. */
19
+ signature: boolean;
20
+ /** Every receipt of one tool call carried the same action group. */
21
+ grouping: boolean;
22
+ /** Cloud acknowledgement. Never `acknowledged` from a local fixture run. */
23
+ cloud_ack: "not_checked" | "acknowledged" | "failed";
24
+ observation_only: boolean;
25
+ /** `source_receipt_hash` of the fixture receipts whose action type is this cell's (allow
26
+ * fixtures, and deny fixtures for a before-action cell). Empty when none matched. */
27
+ proof_digests?: string[];
28
+ /** Typed-operation cells only: every fixture derived the closed operation it expects. */
29
+ typed_operation?: boolean;
30
+ }
31
+ export interface CapabilityCell {
32
+ key: string;
33
+ connector: "scopebond-hook";
34
+ adapter_version: string;
35
+ host_variant: HostVariant;
36
+ action_type: string;
37
+ event_phase: EventPhase;
38
+ state: CapabilityState;
39
+ /** Why the cell is in this state, in plain words. */
40
+ reason: string;
41
+ pre_action: boolean;
42
+ after_action: boolean;
43
+ /** Where enforcement happens: the harness's own hook, or nowhere. */
44
+ boundary: "harness_hook" | "none";
45
+ observation_only: boolean;
46
+ emitted_required_fields: string[];
47
+ supported_operations: string[];
48
+ /** Limits the vectors document; repeated here rather than hidden. */
49
+ known_gaps: string[];
50
+ min_runtime: {
51
+ node: string;
52
+ };
53
+ test_vector_digest: string | null;
54
+ last_proof: ProofRecord | null;
55
+ }
56
+ export interface Manifest {
57
+ connector: "scopebond-hook";
58
+ adapter_version: string;
59
+ generated_at: string;
60
+ cells: CapabilityCell[];
61
+ }
62
+ export declare const cellKey: (adapterVersion: string, host: HostVariant, actionType: string, phase: EventPhase) => string;
63
+ /** The vectors that prove one cell: those tagged with its action type for its adapter. For
64
+ * the after-action cell only the after-action vector counts. */
65
+ export declare function vectorsForCell(adapter: Adapter, actionType: string, phase: EventPhase): Vector[];
66
+ /** Digest of a cell's vectors (id, payload and expectation). A proof recorded against one
67
+ * digest says nothing about a changed set. */
68
+ export declare function vectorDigest(vectors: readonly Vector[]): string | null;
69
+ export interface ManifestInput {
70
+ adapterVersion: string;
71
+ /** Which harnesses have the hook configured (any scope). */
72
+ configured: Record<Adapter, boolean>;
73
+ /** Recorded proofs by cell key. */
74
+ proofs?: Record<string, ProofRecord | undefined>;
75
+ now?: Date;
76
+ /** Cell keys (any version) whose adapter no longer ships, for history. */
77
+ retired?: string[];
78
+ }
79
+ /** Compute the state of one cell from what is configured and what has been proven. */
80
+ export declare function cellState(input: {
81
+ supported: boolean;
82
+ unsupportedReason?: string;
83
+ configured: boolean;
84
+ digest: string | null;
85
+ adapterVersion: string;
86
+ proof: ProofRecord | null | undefined;
87
+ observationOnly: boolean;
88
+ retired?: boolean;
89
+ }): {
90
+ state: CapabilityState;
91
+ reason: string;
92
+ };
93
+ /** The manifest for the installed hook. Pure: no filesystem, no clock beyond `now`. */
94
+ export declare function computeManifest(input: ManifestInput): Manifest;
95
+ /** The spec for a cell key, for the proof runner. */
96
+ export declare function specForCell(cell: CapabilityCell): {
97
+ adapter: Adapter;
98
+ action_type: string;
99
+ phase: EventPhase;
100
+ } | null;
101
+ /** Plain-text rendering. Unsupported stays unsupported; nothing here upgrades a state. */
102
+ export declare function renderManifest(manifest: Manifest): string;
103
+ //# sourceMappingURL=capabilities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capabilities.d.ts","sourceRoot":"","sources":["../src/capabilities.ts"],"names":[],"mappings":"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"}