@scopebond/hook 0.8.1 → 0.10.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/README.md +239 -0
- package/dist/budget-load.d.ts +68 -0
- package/dist/budget-load.d.ts.map +1 -0
- package/dist/budget-load.js +164 -0
- package/dist/budget-load.js.map +1 -0
- package/dist/capabilities.d.ts +103 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +212 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/classify.d.ts +17 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +78 -0
- package/dist/classify.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +585 -13
- package/dist/cli.js.map +1 -1
- package/dist/cloud.d.ts +8 -0
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js.map +1 -1
- package/dist/dispatch-cli.d.ts +2 -0
- package/dist/dispatch-cli.d.ts.map +1 -0
- package/dist/dispatch-cli.js +128 -0
- package/dist/dispatch-cli.js.map +1 -0
- package/dist/explain.d.ts.map +1 -1
- package/dist/explain.js +2 -1
- package/dist/explain.js.map +1 -1
- package/dist/group.d.ts +12 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/group.js +55 -0
- package/dist/group.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +10 -0
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +56 -0
- package/dist/install.js.map +1 -1
- package/dist/managed.d.ts +69 -0
- package/dist/managed.d.ts.map +1 -0
- package/dist/managed.js +184 -0
- package/dist/managed.js.map +1 -0
- package/dist/map.d.ts +4 -0
- package/dist/map.d.ts.map +1 -1
- package/dist/map.js +29 -1
- package/dist/map.js.map +1 -1
- package/dist/obs-emitter.d.ts +132 -0
- package/dist/obs-emitter.d.ts.map +1 -0
- package/dist/obs-emitter.js +415 -0
- package/dist/obs-emitter.js.map +1 -0
- package/dist/obs-store.d.ts +160 -0
- package/dist/obs-store.d.ts.map +1 -0
- package/dist/obs-store.js +367 -0
- package/dist/obs-store.js.map +1 -0
- package/dist/obs-upload.d.ts +24 -0
- package/dist/obs-upload.d.ts.map +1 -0
- package/dist/obs-upload.js +231 -0
- package/dist/obs-upload.js.map +1 -0
- package/dist/observation.d.ts +232 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/observation.js +308 -0
- package/dist/observation.js.map +1 -0
- package/dist/paths.d.ts +15 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +79 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy-load.d.ts +60 -0
- package/dist/policy-load.d.ts.map +1 -0
- package/dist/policy-load.js +151 -0
- package/dist/policy-load.js.map +1 -0
- package/dist/policy-sync.d.ts +45 -0
- package/dist/policy-sync.d.ts.map +1 -0
- package/dist/policy-sync.js +171 -0
- package/dist/policy-sync.js.map +1 -0
- package/dist/proof.d.ts +30 -0
- package/dist/proof.d.ts.map +1 -0
- package/dist/proof.js +186 -0
- package/dist/proof.js.map +1 -0
- package/dist/rules.d.ts +20 -0
- package/dist/rules.d.ts.map +1 -1
- package/dist/rules.js +50 -0
- package/dist/rules.js.map +1 -1
- package/dist/runtime.d.ts +21 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +77 -10
- package/dist/runtime.js.map +1 -1
- package/dist/scan.d.ts +7 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +38 -0
- package/dist/scan.js.map +1 -0
- package/dist/shell.d.ts.map +1 -1
- package/dist/shell.js +12 -3
- package/dist/shell.js.map +1 -1
- package/dist/sql-classify.d.ts +11 -0
- package/dist/sql-classify.d.ts.map +1 -0
- package/dist/sql-classify.js +251 -0
- package/dist/sql-classify.js.map +1 -0
- package/dist/typed-infra.d.ts +73 -0
- package/dist/typed-infra.d.ts.map +1 -0
- package/dist/typed-infra.js +758 -0
- package/dist/typed-infra.js.map +1 -0
- package/dist/typed-ops.d.ts +140 -0
- package/dist/typed-ops.d.ts.map +1 -0
- package/dist/typed-ops.js +549 -0
- package/dist/typed-ops.js.map +1 -0
- package/dist/vectors.d.ts +40 -0
- package/dist/vectors.d.ts.map +1 -0
- package/dist/vectors.js +219 -0
- package/dist/vectors.js.map +1 -0
- package/package.json +5 -5
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,198 @@ 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
|
+
### Rules set by your workspace
|
|
220
|
+
|
|
221
|
+
A connected computer keeps its own rules (`.scopebond/rules.json`) until someone who manages
|
|
222
|
+
the workspace changes a rule for it there. From then on the workspace decides, rule by rule,
|
|
223
|
+
whether a matching action is **blocked** or only **recorded**, and can add entries to this
|
|
224
|
+
computer's lists (protected branches, programs, allowed sites). The workspace never sends
|
|
225
|
+
patterns: the hook compiles its choices with the same compiler as `rules apply`.
|
|
226
|
+
|
|
227
|
+
- **When it applies.** At most once every five minutes, a tool call also checks for changes,
|
|
228
|
+
alongside sending its activity record and capped at about one and a half seconds
|
|
229
|
+
(`SCOPEBOND_POLICY_SYNC_MS`); every other call only reads two small files. A change therefore
|
|
230
|
+
applies within a few minutes of the agent's next action, and a workspace that is slow or
|
|
231
|
+
unreachable never holds up the agent for longer than the cap. The new rules govern from the
|
|
232
|
+
next action. `npx @scopebond/hook policy sync` checks right now.
|
|
233
|
+
- **What is checked.** A rules document must be complete, issued for this computer, match its
|
|
234
|
+
digest and be newer than the one in force; the resulting policy must load. Anything else is
|
|
235
|
+
refused, the rules already in force stay, and the refusal is reported to the workspace.
|
|
236
|
+
- **What is confirmed.** After loading, the hook tells the workspace exactly which version it
|
|
237
|
+
loaded, so the workspace shows *Applied* only for computers that confirmed it.
|
|
238
|
+
- **What the workspace cannot change.** Protection of Scopebond's own settings and of the
|
|
239
|
+
agents' hook settings, the machine key policy, fail-closed handling of anything unreadable,
|
|
240
|
+
and this computer's own opt-ins (`allowed_roots`, `protect_remote_database`).
|
|
241
|
+
- **Going back.** While the workspace sets the rules, `rules` edits and `policy load` are
|
|
242
|
+
refused here. If the connection is revoked, or the workspace stops setting rules for this
|
|
243
|
+
computer, the hook recompiles `policy.json` from `rules.json`: a computer is never left
|
|
244
|
+
without rules. `status` shows which rules are in force and when they were last checked.
|
|
245
|
+
|
|
246
|
+
Set `SCOPEBOND_POLICY_SYNC=off` to stop the five-minute check (the rules in force stay).
|
|
247
|
+
|
|
248
|
+
### Session, health and action observations (opt-in)
|
|
249
|
+
|
|
250
|
+
Beyond receipts, the hook can send a second kind of signed record, an *observation*,
|
|
251
|
+
to a workspace that supports them. It is **off unless your workspace enrollment grants
|
|
252
|
+
`observations:write`**, and it needs the enrollment to give an installation id (the
|
|
253
|
+
enrollment's `gateway_id`, or an explicit `installation_id`) and an
|
|
254
|
+
`installation_generation`; without those the hook says so in `scopebond status` and
|
|
255
|
+
emits nothing. It never guesses a generation. It
|
|
256
|
+
never affects a decision: sending is best effort, bounded, and runs after the decision is
|
|
257
|
+
made, so an unreachable workspace, a missing route or a rate limit changes nothing about
|
|
258
|
+
what is allowed or denied.
|
|
259
|
+
|
|
260
|
+
Each observation is Ed25519-signed with the enrolled agent key over the domain
|
|
261
|
+
`scopebond:observation/v1` plus a newline and the canonical (RFC 8785) payload, and sent
|
|
262
|
+
in batches to `POST /v1/observations` as `{ version: "1.0", items: [{ payload, signature }] }`.
|
|
263
|
+
A separate keyed digest binds each tool-call observation to the request the hook actually
|
|
264
|
+
evaluated; the key stays on this machine and is never uploaded. Paths, commands, hosts and
|
|
265
|
+
session ids never leave the machine: they are reduced to keyed opaque ids or closed enums.
|
|
266
|
+
|
|
267
|
+
| Kind | Sent when | Hosts |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `session` start / stop | Claude Code `SessionStart` / `SessionEnd` (stop reasons: completed, cancelled, unknown; sleep is inferred) | Claude Code |
|
|
270
|
+
| `health` heartbeat | every 60 seconds while a session is explicitly active, from one short helper per session that ends with the session | Claude Code |
|
|
271
|
+
| `health` queue | oldest pending receipt time and count, at most every five minutes while a backlog exists | Claude Code |
|
|
272
|
+
| `tool_intent` | each evaluated action, linked to its receipt | Claude Code, Codex, Cursor (shell, file, git push, MCP) |
|
|
273
|
+
| `tool_outcome` | Claude Code `PostToolUse` / `PostToolUseFailure`, echoing the intent's binding | Claude Code |
|
|
274
|
+
| `capability` proof | `scopebond capabilities --prove` (marked as a fixture run, never live) | all |
|
|
275
|
+
| `policy_ack` | `scopebond policy load <export.json>`, once an exported policy is loaded or refused | all |
|
|
276
|
+
|
|
277
|
+
With observations on, `capabilities --prove` signs its fixtures with this machine's own keys
|
|
278
|
+
(copied into the temporary directory; the originals are never changed), delivers the fixture
|
|
279
|
+
receipts to the workspace first, and only then sends each proof, naming those receipts by
|
|
280
|
+
`proof_digests`: the SHA-256 of `scopebond:source-receipt/v1` plus a newline plus the
|
|
281
|
+
canonical full signed receipt. Only receipts of the cell's own action type are named. If the
|
|
282
|
+
receipts cannot be delivered, no proof is sent. The fixture receipts are real receipts in
|
|
283
|
+
the workspace's log.
|
|
284
|
+
|
|
285
|
+
`scopebond policy load <export.json>` checks a policy exported from the workspace (its
|
|
286
|
+
policy hash must match the policy, its scope digest must match the export, agent and
|
|
287
|
+
environment, and the environment must be the one this machine is connected to) and says
|
|
288
|
+
what loading would replace; `--yes` writes it atomically as `policy.json` (the old one is
|
|
289
|
+
kept as `policy.previous.json`). It then acknowledges the load, or the refusal with a
|
|
290
|
+
reason, echoing the export's policy hash, policy id, policy version and scope digest
|
|
291
|
+
exactly. The export itself carries no signature, so get the file from your workspace.
|
|
292
|
+
`scopebond rules apply` recompiles `policy.json` from `rules.json` and would replace a loaded policy.
|
|
293
|
+
|
|
294
|
+
`scopebond budget load <export.json>` does the same for an action budget exported from the
|
|
295
|
+
workspace: it checks the document type and version, the policy digest (over the policy
|
|
296
|
+
without its acknowledgement), the scope digest, the environment, the validity window and the
|
|
297
|
+
fail-closed contract, and refuses a budget an independent installation cannot enforce (one
|
|
298
|
+
shared across installations). `--yes` writes it into `dispatch.json` as an acknowledged
|
|
299
|
+
budget for this agent (replacing an older workspace budget, never a newer one) and queues
|
|
300
|
+
the acknowledgement with the export id, budget id and version, and digests the workspace
|
|
301
|
+
expects. The export names the agent key it is for (`agent_kid`): a different key than this
|
|
302
|
+
machine's is refused (and acknowledged as rejected); an export whose key the workspace could not
|
|
303
|
+
state (null) loads with a warning that its identity could not be bound, and an export without
|
|
304
|
+
the field is refused. The budget's actor is that key. An enforced budget denies new dispatch
|
|
305
|
+
once its export has expired, until you load a new one.
|
|
306
|
+
|
|
307
|
+
When this machine is connected with `observations:write`, the dispatch boundary can also use
|
|
308
|
+
the workspace: an approval granted there for exactly the request is found and consumed at
|
|
309
|
+
dispatch with nothing to copy (the guard asks `GET /v1/monitoring/approvals/active` with the
|
|
310
|
+
request hash, action type and opaque target id; the denial message still prints the request
|
|
311
|
+
hash and target id to approve). Leaving `{ "cloud_approval_id": "<id>" }` in `approvals/`
|
|
312
|
+
still works and is used first. Only the consume approves: a lookup that finds nothing, fails
|
|
313
|
+
or finds another request approves nothing. A delegated session this machine does not know is resolved from the workspace
|
|
314
|
+
(scope entries are digests of `action_type NUL target-id` or `action_type NUL *`, over the
|
|
315
|
+
same opaque target id; the workspace's own `covers` answer for the action decides when it
|
|
316
|
+
gives one) and cached for 15 seconds. With approvals required for an action type in
|
|
317
|
+
`dispatch.json`, each typed operation also carries `approval_request_hash` (the hash the guard
|
|
318
|
+
consumes with) and a `resource_id` equal to the guard's target id, so a consumed approval can be
|
|
319
|
+
matched to its intent; the field is a claim and authorizes nothing. If the workspace cannot be reached and no valid local approval is
|
|
320
|
+
presented, an enforced action is denied. Targets reach the workspace only as keyed opaque ids.
|
|
321
|
+
Set `"cloud": false` in `dispatch.json` to keep everything local.
|
|
322
|
+
|
|
323
|
+
#### Typed operations: git, GitHub and package installs
|
|
324
|
+
|
|
325
|
+
A `tool_intent` carries one closed typed operation read from the request the hook is about to
|
|
326
|
+
allow: the raw shell command or the MCP tool input, never a model-written summary. It replaces the
|
|
327
|
+
generic shell operation for the same receipt when it can be described:
|
|
328
|
+
|
|
329
|
+
| Operation | From | What it carries |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| `git` commit | `git commit` | repository id, current branch as a keyed ref id with a protected flag, HEAD before the commit |
|
|
332
|
+
| `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` |
|
|
333
|
+
| `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 |
|
|
334
|
+
| `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 |
|
|
335
|
+
|
|
336
|
+
Unknown facts stay unknown. An unresolved ref or remote is `resolution: "unresolved"`;
|
|
337
|
+
`integrity_status` is always `unknown` because nothing is verified before the install runs; lifecycle
|
|
338
|
+
scripts are `blocked` only for `--ignore-scripts`, `unknown` for npm, pnpm and yarn otherwise, and
|
|
339
|
+
`not_supported` for pip and uv. A command that installs whatever a lockfile or requirements file lists
|
|
340
|
+
(`npm ci`, `pnpm install`, `pip install -r`, `uv sync`) names no packages and stays a plain shell
|
|
341
|
+
operation; so does anything after a `cd` in the same command line, `git -C`, a pull request from a fork
|
|
342
|
+
or another repository, and `gh pr edit` or `gh pr merge`, whose pull request is named only by number and
|
|
343
|
+
has no head or base commit without a platform read-back this hook does not do (the capability manifest lists
|
|
344
|
+
`github.pr_change` and `deploy.run` as unsupported). Non-registry package sources are named `url:`,
|
|
345
|
+
`git:` or `local:` with credentials and queries dropped. These cells are observation-only and stay
|
|
346
|
+
`configured_unverified` after `capabilities --prove` (a fixture proves the derivation on this machine, not the host);
|
|
347
|
+
no proof is sent for them because they have no receipt of their own action type to name.
|
|
348
|
+
|
|
349
|
+
Reference sets refer to refs, remotes and repositories by these keyed ids. `scopebond observations id ref main`,
|
|
350
|
+
`observations id remote <url>`, `observations id ghrepo owner/name` and `observations id mcp <server> <tool>`
|
|
351
|
+
print the id this installation gives a value; it reads the local key and sends nothing.
|
|
352
|
+
|
|
353
|
+
#### Typed operations: network, Cloudflare and databases
|
|
354
|
+
|
|
355
|
+
The same rules apply: the operation is read from the raw command (or a Claude Code `WebFetch` input),
|
|
356
|
+
unknown facts stay unknown, and a command the reader cannot place fully stays a plain shell operation.
|
|
357
|
+
|
|
358
|
+
| Operation | From | What it carries |
|
|
359
|
+
|---|---|---|
|
|
360
|
+
| `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) |
|
|
361
|
+
| `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 |
|
|
362
|
+
| `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 |
|
|
363
|
+
|
|
364
|
+
What never leaves the machine: the URL path, query and credentials, headers and request bodies; SQL text,
|
|
365
|
+
table and column names, literals and rows; recipient details; file contents. The database digest is an HMAC under
|
|
366
|
+
the installation key, so another installation cannot reproduce it. The SQL classifier is deliberately
|
|
367
|
+
conservative: an `UPDATE` or `DELETE` counts as bounded only when a top-level AND-ed condition is selective (an OR,
|
|
368
|
+
a tautology such as `1=1`, `LIKE '%'` or `IS NOT NULL` alone counts as every row), and SQL it cannot place (dynamic
|
|
369
|
+
SQL, `CALL`, `VACUUM`, a `CTE` that writes, an unbalanced quote) produces no database operation at all, because the
|
|
370
|
+
closed schema has no unknown verb.
|
|
371
|
+
|
|
372
|
+
Known limits, said plainly: a redirect hop is never followed or bound (`redirect_binding` is always omitted, so a
|
|
373
|
+
redirected request is not qualified against the origin's approval); a request sent through a proxy,
|
|
374
|
+
`--resolve`, `--connect-to`, a config file or more than one URL is not described; IPv6 literals and non-HTTP
|
|
375
|
+
schemes are not described; `wrangler` has no DNS command, so `dns_record` changes are not seen; `d1 execute` records
|
|
376
|
+
the environment only from `--local` or `--env`, and `environment_class` is otherwise `unknown` (the hook cannot tell which database
|
|
377
|
+
is production); a `d1 migrations apply` digest covers the whole local migrations directory, not only the pending files;
|
|
378
|
+
account ids come from an inline `CLOUDFLARE_ACCOUNT_ID=`, the Wrangler config or this process's environment and are
|
|
379
|
+
`unbound` when none names one. The shell receipt of a command still carries the redacted command head the hook has
|
|
380
|
+
always recorded (a scrubbed prefix of up to 64 characters), which can include the start of an inline SQL statement.
|
|
381
|
+
|
|
382
|
+
Capability cells `network.request`, `cloudflare.resource` and `database.exec` are observation-only and stay
|
|
383
|
+
`configured_unverified` without a real-host proof. `browser.action`, `communication.send` and `visibility.change`
|
|
384
|
+
are registered as `unsupported` with their reasons: no host this hook supports sends an approved browser or
|
|
385
|
+
communications event (a browser or mail tool arrives as a generic MCP call with no origin, verb, destination or
|
|
386
|
+
attachment set), and no supported source reports a resource's visibility before and after.
|
|
387
|
+
`observations id net-dest <host> <port>`, `observations id cf <kind> <name>` and `observations id database <pg|sqlite> <key>`
|
|
388
|
+
print the keyed ids for writing reference sets.
|
|
389
|
+
|
|
390
|
+
**Optional enforcement.** `scopebond-hook rules protect-remote-database` (off by default; `rules.json` field
|
|
391
|
+
`protect_remote_database`) adds an enforce clause that denies, before the command runs, SQL against a remote
|
|
392
|
+
database (a `wrangler d1` command without `--local`, or `psql` to a host other than this machine) that drops a table,
|
|
393
|
+
deletes or updates every row, drops or renames inside an `ALTER`, or cannot be read. The hook cannot tell which
|
|
394
|
+
database is production, so every remote database is covered; `wrangler d1 execute` with neither `--local` nor `--remote`
|
|
395
|
+
is treated as remote because Wrangler's default has differed between versions. Everything else stays observation.
|
|
396
|
+
|
|
397
|
+
Codex and Cursor have no tested session or after-action mapping, so those kinds are not
|
|
398
|
+
sent for them. Nothing is sent from a sleeping host: a heartbeat gap is recorded as a stop
|
|
399
|
+
with reason `sleep`, and an idle session releases its lease with one final heartbeat.
|
|
400
|
+
|
|
401
|
+
Delivery follows the workspace's answers: only acknowledged items are removed, deferred
|
|
402
|
+
items are retried with the same id and sequence, and items the workspace refuses stay in a
|
|
403
|
+
local terminal-error queue shown by `scopebond observations status --refused` (an
|
|
404
|
+
unsupported version or a stale generation is never retried; a rate limit, a paused plan
|
|
405
|
+
(HTTP 402) and a refusal without an item id are handled as the workspace reports them). A workspace without the route
|
|
406
|
+
is marked unsupported and nothing more is queued until `scopebond observations retry`.
|
|
407
|
+
`scopebond observations wire` adds the Claude Code session and after-action hook entries
|
|
408
|
+
(`connect` does it automatically when the enrollment grants the scope); `unwire` removes
|
|
409
|
+
only those. `SCOPEBOND_OBSERVATIONS_HEARTBEAT=off` keeps everything except the heartbeat helper.
|
|
410
|
+
|
|
172
411
|
## How it works
|
|
173
412
|
|
|
174
413
|
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"]}
|