@bongos/core 1.19.962 → 1.19.963

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.962",
6
- "core_contract": "1.19.962",
7
- "source_commit": "31bf829b41365d773f768e041a5593502bf02e0f",
5
+ "core_version": "1.19.963",
6
+ "core_contract": "1.19.963",
7
+ "source_commit": "faa62c8ba55aa4e1fa7cf848ae917ed2c737dd8a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-22T17:31:08.462Z",
9
+ "built_at": "2026-09-22T18:58:15.932Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 514,
12
+ "docs_redacted": 515,
13
13
  "agent_docs_stubbed": 26,
14
14
  "functional_verbatim": 2358,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2898,
20
- "tree_sha256": "51d873c73f0ab88e3ec4e1baf9ec1d53a22994eeda22ed4dade6355993f5d527",
19
+ "file_count": 2899,
20
+ "tree_sha256": "cd552e778cb5f18f556a89d67c09fe6be5f78390a2473d72be88a34eea0c2a2d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2119,10 +2119,15 @@
2119
2119
  "mode": "0000644",
2120
2120
  "sha256": "6ae902e7626feb915300f4b21b6d20ae3369116a86bf624bd614ac9616c3ac11"
2121
2121
  },
2122
+ {
2123
+ "path": "docs/adr/0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md",
2124
+ "mode": "0000644",
2125
+ "sha256": "993ce83a347058782cff6e07e4191ced95bd0c07b61285f6327feb419e9b0dfd"
2126
+ },
2122
2127
  {
2123
2128
  "path": "docs/adr/README.md",
2124
2129
  "mode": "0000644",
2125
- "sha256": "5db407e3565216666055365b11a5ff05b8901ff9c7fbaee011c93784c40762e6"
2130
+ "sha256": "7bfacf7f4f5ed67202cf4f2798b4e9b04f8fd1a596e22bc72628b29344f134ee"
2126
2131
  },
2127
2132
  {
2128
2133
  "path": "docs/api-reference.md",
@@ -2692,7 +2697,7 @@
2692
2697
  {
2693
2698
  "path": "docs/module-api-changelog.md",
2694
2699
  "mode": "0000644",
2695
- "sha256": "fa3d4990413dcbd0bdf03dc4c6d3dbb4a8e966146fc594a914f4951468204668"
2700
+ "sha256": "a3606d100eb285f3fb82a01c8602db6d342a4d47a969214b87b8616ba67fd35d"
2696
2701
  },
2697
2702
  {
2698
2703
  "path": "docs/modules-contract.md",
@@ -8082,12 +8087,12 @@
8082
8087
  {
8083
8088
  "path": "package-lock.json",
8084
8089
  "mode": "0000644",
8085
- "sha256": "de49dbe2000a605cf6e9d07c5c24aa63047624d3ab54c12802b877b5ed75c7b4"
8090
+ "sha256": "5453556ff16b658bfd7f82f6659d6b2e38abafecfd1c51e70c15d1e61ea5e6a9"
8086
8091
  },
8087
8092
  {
8088
8093
  "path": "package.json",
8089
8094
  "mode": "0000644",
8090
- "sha256": "0ceb618ebe37f5ce151ca6613d40ab19916b99395de3f0b6848b64d7ebf74057"
8095
+ "sha256": "b5c17e5a3b966a08c4b4f31b5361cc147dc3dcf7a794ccb0dc208ef16f4eef73"
8091
8096
  },
8092
8097
  {
8093
8098
  "path": "public-docs/index.html",
@@ -8432,7 +8437,7 @@
8432
8437
  {
8433
8438
  "path": "scripts/gds/claude-materialize.js",
8434
8439
  "mode": "0000644",
8435
- "sha256": "1bee053fafde559fad140069438ef41a3b5f8055cdc6330f159b7f9b4e9994c6"
8440
+ "sha256": "146844bd64dc10f5ae06dab42ff2f711c08af450edb58d765ba976b84d7fb6ca"
8436
8441
  },
8437
8442
  {
8438
8443
  "path": "scripts/gds/claude-md-template.js",
@@ -10092,7 +10097,7 @@
10092
10097
  {
10093
10098
  "path": "src/module-api.js",
10094
10099
  "mode": "0000644",
10095
- "sha256": "4147e643fea1c191af854b7b08b98a6f6c8e49e384b10f7854b7213a0225f52a"
10100
+ "sha256": "7a59066fd558222ba904ef314cea8f2145c85f00efbb6a0d6d811c0c4d527564"
10096
10101
  },
10097
10102
  {
10098
10103
  "path": "src/module-loader/catalog.js",
@@ -10872,7 +10877,7 @@
10872
10877
  {
10873
10878
  "path": "tests/claude_materialize.mjs",
10874
10879
  "mode": "0000644",
10875
- "sha256": "256b4192e1ef8fb43539313c569b21435926ef9d149b04cd4903e342fcf14d45"
10880
+ "sha256": "0771634ad696014cb6307ca82c7d20fdb3ccad809a7ea463b5866913ec046812"
10876
10881
  },
10877
10882
  {
10878
10883
  "path": "tests/clawback_confirmed_backlog.mjs",
@@ -0,0 +1,46 @@
1
+ # ADR 0326 — An upgrade may ADD a deny rule to a file the instance owns
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-22
5
+ - **Task:** [task 1004156](https://cloudbongos.com/builders#/task/1004156) (goal 1000090)
6
+ - **Deciders:** the owner, asked directly. The question is theirs because it narrows a rule the project drew on purpose; the implementation follows from the answer.
7
+ - **Amends:** [ADR 0121](0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md) §2 — which already licensed "merge **or** leave-and-report" for a pre-existing file; this names the one merge that is provably safe, and extends it from `--adopt` to every materialize
8
+ - **Related:** task 1003962 (the allow-only starter) · task 1003966 (the deny half) · [task 1004155](https://cloudbongos.com/builders#/task/1004155) (the core's own list) · task 1004023 (an unparseable settings.json is copied through, not mangled)
9
+
10
+ ## Context
11
+
12
+ Task 1003966 gave the package-only instance starter its deny rules: no reading `.env` files, keys, or session tokens, and no `git stash`. It reached nobody who was already here.
13
+
14
+ The starter is written **only when `.claude/settings.json` is absent**. `bongos upgrade` materializes from the published package, which excludes `.claude/settings.json` on purpose (it is topology, not core), so an upgrade *always* takes the starter branch, *always* finds a settings.json already there, and left it alone. Every instance that took the allow-only starter from task 1003962 — the live one included — therefore kept it forever, and its local agent could still read keys and session tokens.
15
+
16
+ That was not an oversight. Task 1003962 drew the line deliberately: *"rewriting a file the instance owns is the line task 1003962 drew."* The prior session log recorded the remedy as manual — re-run `bongos init`, or delete the file so the next materialize writes a fresh starter.
17
+
18
+ The problem with a manual remedy for a security gap is that it protects the instances whose owners read release notes, which is not the set of instances that needs protecting.
19
+
20
+ ## Decision
21
+
22
+ **`bongos upgrade` adds a missing starter deny rule to an instance's own `settings.json`, and names each rule it added in the run output.**
23
+
24
+ The argument is that a deny rule is **monotonic in the safe direction**. An allow rule widens what the local agent may do, so writing one into a file the instance owns could hand an agent authority its owner never granted. A deny rule can only narrow. Appending one therefore cannot make any instance less safe than it was a moment earlier, whatever the file already said — which is what makes this the one merge worth doing without asking per-instance.
25
+
26
+ "Never clobber" survives in the sense that was always load-bearing: **nothing the instance wrote is removed, reordered, or rewritten.** Their allow rules, their own deny rules, their `ask` list, their hooks, their env, and every key outside `permissions` come back exactly as they were. New rules are appended after the ones already there.
27
+
28
+ ### The one rule the monotonicity argument does not fully cover
29
+
30
+ The credential rules are pure safety: nothing an instance legitimately does requires reading its own session token from inside an agent session. **The two shelf rules (`Bash(git stash:*)`, `PowerShell(git stash:*)`) are different in kind** — they guard a *workflow*, not a secret. They are in the top-up set anyway, because task 1004156's done-when says an old instance ends up with *the starter's* deny rules and because for an instance they are the only layer that exists (the `git-guard` hook is topology and does not ship).
31
+
32
+ The consequence is worth stating plainly rather than discovering: an instance whose builders used the repo-global shelf as a set-aside fallback will find it blocked after its next upgrade, and — no provenance, deny beating allow — cannot re-enable it from `settings.json`. Every added rule is named in the run output, so this is visible when it happens. If it ever bites, excluding the two shelf rules from the top-up set (keeping them starter-only) is a one-line change.
33
+
34
+ ### What is deliberately NOT done
35
+
36
+ - **No provenance, and so no memory of a deletion.** An owner who deliberately removes a rule we add will get it back on the next upgrade, and because deny beats allow in Claude Code's permission order they cannot override it with an allow rule either. We considered recording what we added so a later deletion could be read as intent. It was rejected as speculative machinery for a case nobody has hit: the rules in question are "do not read credentials" and "do not `git stash`", and an instance that genuinely needs one lifted is a conversation, not a config toggle. Revisit if it is ever a real complaint.
37
+ - **A file we cannot parse is left byte-for-byte alone** and reported, the same posture task 1004023 took when the core's own settings.json would not parse. So is one whose `permissions` or `permissions.deny` is not the shape we expect. A half-understood rewrite of a permissions file is worse than the gap it closes.
38
+ - **The allow half is never topped up.** Only deny. The whole safety argument is directionality, and it does not survive being applied to a rule that grants.
39
+
40
+ ## Consequences
41
+
42
+ - An instance materialized before task 1003966 is protected on its next upgrade, with no action from its owner and nothing of theirs lost.
43
+ - `materializeClaude` gains `summary.settingsDenyAdded` (the rules added) and `summary.settingsDenyUncheckable` (why a file was skipped). Both are also printed through `log()` — `upgrade.js` does not iterate `summary.notes`, so a note alone would never reach the operator's terminal.
44
+ - **The top-up is reported in `writtenPaths` on the real run only, never on the dry run.** That list doubles as the set `bongos upgrade`'s clean-tree pre-flight *ignores* (task 1004122), and the waiver is justified by "materialize overwrites these unconditionally, so whatever they hold now is discarded either way" — which is exactly not true of a top-up, the one path here that preserves existing content. Listing it in the preview would stop an owner's half-finished edit to `settings.json` from halting the bump, and `--commit-pin` would then commit and push it unreviewed. Omitting it from the preview keeps that safety property; reporting it on the real run still hands it to `persistPin`, so the pin commit carries the added rules and the next upgrade does not read them as a human's stray work.
45
+ - A read that throws (a directory at that path, a locked or root-owned file) is caught and reported as uncheckable. This step runs *after* the pin moved and the migrations ran, so an uncaught throw would leave a half-applied upgrade.
46
+ - The test that asserted an instance's settings.json comes back **byte-identical** is superseded by a strictly stronger one: every rule it had is still there, unmoved, and it gained only deny rules. The weaker spelling was what froze the gap in place.
@@ -417,3 +417,4 @@ This keeps the decision history honest and traceable.
417
417
  | 0323 | [**Hosting is three shapes, and we are not the landlord** ([task 1004140](https://cloudbongos.com/builders#/task/1004140), goal 1000106 — BONGOS-V2). Five projects ran as one Node process each on the control-plane droplet, about $0.80/month apiece — the cheapest arrangement available, bought with ~4,600 lines of `provision*.js` that write systemd units, create unix accounts, render Caddy snippets, `createdb`, install backup timers and count memory against a ceiling. That is the cost nobody priced: **we are a landlord.** The owner reframed the question — build on what others maintain, and let an owner keep the deployment platform they already use. **Seven decisions.** The shapes become `byo-host` (the owner runs it), `cloud-host` (we stand it up for them) and `dedicated-cloud-host` (the same path, larger plan); `co-tenant` is **retired, not renamed**, because it means *a process on our box from our checkout* — the opposite of bring-your-own-host, so reusing the name would make every existing comment lie. **DigitalOcean hosts hubs only**, which closes [ADR 0145](0145-free-hosted-project-tier-isolation-and-domain-separation.md) Finding 1 by removing its precondition rather than hardening the cookie: with no customer code on a `cloudbongos.com` subdomain there is no tenant to receive the hub session. **Render is the default** — its Postgres carries PITR and automated backups on every paid tier; Fly's managed Postgres starts ~$38/mo and Railway's is explicitly unmanaged, which is the exact responsibility being shed. **Cloudflare is the front door, not the compute**: Workers is a V8-isolate runtime that would require rewriting Express and `pg`, and Containers is built for bursty workloads that sleep, which a hall with background sweeps and a live event stream does not survive. **No deployment-platform adapter is built at all** — their application already deploys itself from the repository, so the platform we do not talk to is the platform we do not have to add. **Secrets: the platform is the system of record and Bongos stores names, never values** — a manifest of name, purpose and environment, no application secret, no platform API token, no "is it set?" probe, because each of those makes us custodian of other companies' credentials for convenience the manifest already delivers; `byo-host` marks the manifest unverifiable rather than opening an exception. **The free tier becomes a capped trial**, since ~$14/project cannot be subsidised indefinitely. Per-project cost rises ~17×, which is the price of not being a landlord and the reason metering stops being deferred work. Mostly a DELETION: [ADR 0306](0306-instance-supervision-is-a-seam-with-systemd-as-the-default.md)'s supervision seam is not extended (Render supervises its own processes), [ADR 0285](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) stays true and stops being load-bearing with its queued resize held rather than spent, and the hub's offsite backup becomes the highest-consequence open item. Rejected: AWS/GCP (2–3× the cost for a bare instance, plus an IAM and billing surface to learn — maintenance added where the goal is to remove it); keeping the shared box and fixing only the cookie (cheaper, closes the finding, leaves the landlord runner and answers nothing else); a Vercel/Netlify/Render adapter layer (speculative pluggability); Bongos as the secrets vault pushing values through platform APIs.](0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md) | hosting / provisioning / cost |
418
418
  | 0324 | [**The public npm CLI channel is retired** ([task 1004144](https://cloudbongos.com/builders#/task/1004144), goal 1000091 — BONGOS-V2). The owner deleted `@cloudbongos/cli` from the registry on 2026-09-22 (`npm view` → E404; the `@cloudbongos` scope is empty). This supersedes [ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md) and [ADR 0292](0292-both-install-paths-stay-and-vendoring-is-the-production-one.md) §2 — the latter titled "`@cloudbongos/cli` STAYS, on a new justification" and written *because* task 1003872 proposed removing it once the public core flip dissolved its original reason. 0292's measured argument (~0.5 MB front door vs a 46 MB core pull to print a task list) is still true and is NOT what changed; the owner's decision about carrying a public channel at all is. `scripts/gds/build-cli-package.js` + `tests/cli_package.mjs` are deleted, no doc offers `npx @cloudbongos/cli`, and the way in is the hall at `<origin>/builders` or a checkout. The COMPILED binary channel (`build-bongos.js` → `/downloads/bongos/*` via `config/bongos-cli.json`) is a different artifact and is untouched — the same near-twin task 1003889 warned about. Three tests used the package's FILES manifest as a stand-in for "what a terminal user can run" and now read `bin/bongos.js`'s verb table, which is what that set always meant.](0324-the-public-npm-cli-channel-is-retired.md) | distribution / CLI |
419
419
  | 0325 | [**The managed fleet control plane is the hub we already run** ([task 1001948](https://cloudbongos.com/builders#/task/1001948), goal 1000106 — BONGOS-V2). The spike was written against [ADR 0111](0111-instance-hosting-provisioning-module.md) §5/§7, which deferred the managed offering as **"genuinely greenfield (no fleet registry exists)"** behind two gates — Mercury and a real `bongos upgrade`. Both gates are shipped and **the premise is not**: measuring the call sites first changed the answer, as it did for [task 1004141](https://cloudbongos.com/builders#/task/1004141). The registry is `provisioning_instances` (owner, shape, domain, database, port, lifecycle, cost estimate, module selection, the liveness overlay and the served/available core-version snapshot), with `provisioning_intents` as the queue and `provisioning_events` as the audit trail; the operator is `scripts/gds/provision.js`, the single token-holder draining that queue behind ADR 0111 §2's trust boundary; oversight is `GET /provisioning/fleet` and `/fleet/cost-ledger`; and the update channel is not one mechanism but two shipped ones — the owner's per-instance deploy door ([ADR 0293](0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md), health-gated with auto-rollback) and the opt-in patch-only sweep ([ADR 0136](0136-update-channel-subscription-policy.md)). **Decision: there is no greenfield fleet control plane to design, and none will be built — the hub IS it.** It never needed to sit *above* the per-repo databases [ADR 0100](0100-two-repo-instance-model.md) §3 locks: the roster is a hub table and the operator has never read one. The genuinely missing leg is a **platform leg** — `grep -rl RENDER_API scripts modules src` returns nothing, and after [ADR 0323](0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md) a `cloud-host` is a Render service — and this ADR **deliberately specifies none of it**, because that is [task 1004142](https://cloudbongos.com/builders#/task/1004142)'s with those files open and a second spec for one implementation is how two drift. The fleet **driven** and the fleet **observed** are different populations: `byo-host` is in the registry and operated by nobody, its liveness the [ADR 0252](0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md) pull, so every operator surface must state which it counts — a roster that mixes them reports a fleet-wide upgrade as complete while an unknown number were never asked. The operator holds **exactly one** credential and it is **its own** (the platform account token, root-only on the control plane, never the web tier, never per-customer); ADR 0323 D6's "names, never values" rule is about the CUSTOMER's secrets and is unchanged. Two gaps are filed rather than fixed here: the subscription roster living in `config/update-subscriptions.json` is the one fleet fact the roster route cannot report and a restored backup can lose independently of the rows it describes ([task 1004150](https://cloudbongos.com/builders#/task/1004150), a recommendation and not a ruling), and **`costEstimateUsd` returns 0 for every shape but `dedicated`** — measured live, all 24 fleet rows report `$0`, confidently and with no error, for projects about to cost ~\$14/month each ([task 1004148](https://cloudbongos.com/builders#/task/1004148)). **No fleet-wide "upgrade everything" button**: both channels are per-instance by construction, and one action that moves the whole fleet is the shape most likely to take every customer down at once. AGPL posture unchanged — ADR 0111 §5's one-sentence rule survives its own table. Rejected: building the separate control plane §5 described (a second registry and a second operator holding the same rows with nothing keeping them equal); specifying Render here; reading per-instance databases for fleet reporting; and continuing to defer the whole layer, which is now the reason the ledger reads \$0 for a fleet about to cost real money.](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) | hosting / fleet / provisioning |
420
+ | 0326 | [**An upgrade may ADD a deny rule to a file the instance owns** ([task 1004156](https://cloudbongos.com/builders#/task/1004156), goal 1000090 — BONGOS-V2). Task 1003966 gave the instance starter its deny rules — no reading `.env` files, keys or session tokens, no shelving with the repo-global stash — and they reached **nobody who was already here**. The starter is written only when `.claude/settings.json` is ABSENT, and `bongos upgrade` materializes from the published package, which excludes that file on purpose; so an upgrade always takes the starter branch, always finds a settings.json already there, and left it alone. Every instance on the allow-only starter from task 1003962 — the live one included — kept it forever. That was deliberate ("rewriting a file the instance owns is the line task 1003962 drew"), and the recorded remedy was manual: re-run `bongos init`, or delete the file. **A manual remedy for a security gap protects the instances whose owners read release notes, which is not the set that needs protecting.** The owner's call: ADD the missing deny rules and NAME each one in the run output. The argument is that a deny rule is **monotonic in the safe direction** — an allow rule widens what the local agent may do, a deny rule can only narrow, so appending one cannot make any instance less safe than it was a moment earlier. That is what makes this the one merge worth doing without asking per-instance, and it extends [ADR 0121](0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md) §2's already-licensed "merge OR leave-and-report" from `--adopt` to every materialize. "Never clobber" survives in the sense that was load-bearing: **nothing the instance wrote is removed, reordered or rewritten** — their allow rules, own denies, `ask` list, hooks, env and every key outside `permissions` come back exactly as they were, with new rules appended after. Deliberately NOT done: **no provenance**, so an owner who deletes a rule we add gets it back next upgrade and (deny beating allow in Claude Code's order) cannot override it with an allow — rejected as speculative machinery for a case nobody has hit, revisit on a real complaint; a file that will not parse, or whose `permissions`/`permissions.deny` is not the expected shape, is left BYTE-FOR-BYTE alone and reported, the posture task 1004023 took for the core's own file, because a half-understood rewrite of a permissions file is worse than the gap; and **the allow half is never topped up** — the safety argument is directionality and does not survive being applied to a rule that grants. `materializeClaude` gains `summary.settingsDenyAdded` + `settingsDenyUncheckable`, both printed through `log()` since `upgrade.js` does not iterate `summary.notes`. The test asserting an instance's settings.json comes back byte-identical is superseded by a strictly stronger one — every rule still there, unmoved, deny-only growth — the weaker spelling being what froze the gap in place.](0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md) | security / instance onboarding |
@@ -2398,5 +2398,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2398
2398
  landed since 1.19.960 with no explicit bump. run 35757951835. (task 1002620)
2399
2399
  1.19.962 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2400
2400
  landed since 1.19.961 with no explicit bump. run 35761137402. (task 1002620)
2401
+ 1.19.963 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2402
+ landed since 1.19.962 with no explicit bump. run 35770645163. (task 1002620)
2401
2403
  ---------------------------------------------------------------------------
2402
2404
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.962",
3
+ "version": "1.19.963",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.962",
9
+ "version": "1.19.963",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.962",
3
+ "version": "1.19.963",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -417,11 +417,43 @@ function starterDeny(branding = {}) {
417
417
  ];
418
418
  }
419
419
 
420
+ // topUpStarterDeny — the deny rules an instance's OWN settings.json is missing, added to it
421
+ // (task 1004156). The starter is only ever written when the file is ABSENT, so every instance
422
+ // that took the allow-only starter from task 1003962 keeps it forever and its local agent can
423
+ // still read .env files, keys and session tokens. ADDING is the whole safety argument: a deny
424
+ // rule can only ever narrow what the agent may do, so appending one cannot widen this instance's
425
+ // permissions — which is why the owner's call (task 1004156) was "add them, and say which".
426
+ // "Never clobber" is unchanged in the sense that matters: nothing the instance wrote is removed
427
+ // or rewritten. Returns { text, added, reason }; `text` is null when nothing should be written.
428
+ // `reason` is the STRUCTURED signal that the file could not be CHECKED — null means "healthy",
429
+ // so callers branch on its presence rather than on the wording of a message. An unparseable or
430
+ // unexpectedly-shaped file is LEFT ALONE and reported, the posture task 1004023 took for a
431
+ // settings.json that will not parse: a half-understood rewrite of a permissions file is worse
432
+ // than the gap it closes. Note the write re-emits the whole document at 2-space JSON, so an
433
+ // instance indented some other way sees a whole-file diff — the RULES are still append-only,
434
+ // which is what the safety argument rests on, and the run output says so.
435
+ function topUpStarterDeny(existingText, wanted) {
436
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
437
+ const cannotCheck = (reason) => ({ text: null, added: [], reason });
438
+ let doc;
439
+ try { doc = JSON.parse(existingText); } catch (err) { return cannotCheck(`not valid JSON (${err.message})`); }
440
+ if (!isObject(doc)) return cannotCheck('its top level is not a JSON object');
441
+ const perms = doc.permissions === undefined ? {} : doc.permissions;
442
+ if (!isObject(perms)) return cannotCheck('its "permissions" is not an object');
443
+ const deny = perms.deny === undefined ? [] : perms.deny;
444
+ if (!Array.isArray(deny)) return cannotCheck('its "permissions.deny" is not an array');
445
+ const have = new Set(deny);
446
+ const added = wanted.filter((rule) => !have.has(rule));
447
+ if (!added.length) return { text: null, added: [], reason: null }; // healthy: every rule already there
448
+ const next = { ...doc, permissions: { ...perms, deny: [...deny, ...added] } };
449
+ return { text: `${JSON.stringify(next, null, 2)}\n`, added, reason: null };
450
+ }
451
+
420
452
  // The starter an instance gets when the core root carries no settings.json — a
421
453
  // package-only install. Permissions ONLY: no hooks (they are topology), no env.
422
454
  function renderStarterSettings(branding = {}) {
423
455
  return `${JSON.stringify({
424
- _doc: 'Starter written by scripts/gds/claude-materialize.js (tasks 1003962, 1003966). Permissions only — the blessed Bongos commands, mirroring the core repo\'s own allow-list (ADR 0104), and the deny rules that keep the local agent from reading keys and session tokens. An allow-rule lets the local agent RUN a command; it grants no authority, which the server decides per request from your live rank (ADR 0016). Yours to edit: a later materialize never clobbers this file.',
456
+ _doc: 'Starter written by scripts/gds/claude-materialize.js (tasks 1003962, 1003966). Permissions only — the blessed Bongos commands, mirroring the core repo\'s own allow-list (ADR 0104), and the deny rules that keep the local agent from reading keys and session tokens. An allow-rule lets the local agent RUN a command; it grants no authority, which the server decides per request from your live rank (ADR 0016). Yours to edit: a later materialize never removes or rewrites a rule you wrote. It will ADD a deny rule this starter has gained since (task 1004156) and name it in the run output — additive only, so it can never widen what the local agent may do.',
425
457
  permissions: { allow: STARTER_ALLOW, deny: starterDeny(branding) },
426
458
  }, null, 2)}\n`;
427
459
  }
@@ -493,11 +525,14 @@ function transformSettings(srcPath) {
493
525
  // Materialize the .claude/ surface from `coreRoot` into `instanceDir`. Returns a summary.
494
526
  // `overwrite` defaults true (greenfield init / upgrade — unchanged). `overwrite: false`
495
527
  // is the `bongos init --adopt` layering path (ADR 0121): every pre-existing .claude/
496
- // file is LEFT untouched and recorded in `summary.skippedFiles` (never clobbered).
528
+ // file is LEFT untouched and recorded in `summary.skippedFiles` (never clobbered). The ONE
529
+ // exception is settings.json in a package-only install, which GAINS any starter deny rule it is
530
+ // missing and reports them in `summary.settingsDenyAdded` (task 1004156) — additive only, so
531
+ // nothing the instance wrote is removed or rewritten.
497
532
  const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
498
533
 
499
534
  function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
500
- const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, settingsUnparsed: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, writtenPaths: [], notes: [] };
535
+ const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, settingsUnparsed: false, settingsDenyAdded: [], settingsDenyUncheckable: null, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, writtenPaths: [], notes: [] };
501
536
  // Every instance-relative path this run WROTE or REMOVED (task 1003957). `bongos upgrade`
502
537
  // hands these to persistPin as artifactPaths, exactly as task 1003926 already does for
503
538
  // regenerateApiArtifacts' output: committing only the pin left the materialized files dirty,
@@ -712,8 +747,40 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
712
747
  // permissions owns them, and a later materialize must never clobber them.
713
748
  const settingsDest = path.join(destClaude, 'settings.json');
714
749
  if (fs.existsSync(settingsDest)) {
715
- summary.skippedFiles.push('settings.json');
716
- log(' • .claude/settings.json already present — left untouched (the instance owns it).');
750
+ // The instance owns this file — but a deny rule the starter has gained since is ADDED to
751
+ // it rather than withheld forever (task 1004156). Nothing of theirs is removed or changed.
752
+ // The read is guarded: existsSync passing does not mean readable (a directory at that path,
753
+ // a locked or root-owned file), and this runs AFTER the pin moved and the migrations ran, so
754
+ // a throw here would leave a half-applied upgrade. It fails soft, like its sibling above.
755
+ let topUp;
756
+ try { topUp = topUpStarterDeny(fs.readFileSync(settingsDest, 'utf8'), starterDeny(toBranding)); }
757
+ catch (err) { topUp = { text: null, added: [], reason: `it could not be read (${err.code || err.message})` }; }
758
+ if (topUp.text) {
759
+ if (!dryRun) fs.writeFileSync(settingsDest, topUp.text);
760
+ // Reported as written only on the REAL run, deliberately. writtenPaths doubles as the set
761
+ // the upgrade pre-flight IGNORES when it checks for a dirty tree (task 1004122), and that
762
+ // waiver is justified by "materialize overwrites these unconditionally, so whatever they
763
+ // hold now is discarded either way" — which is precisely NOT true of a top-up, the one
764
+ // path here that preserves existing content. Leaving it out of the dry-run preview keeps
765
+ // an owner's uncommitted edit to settings.json halting the bump, as it always did; putting
766
+ // it in the real run's summary still hands it to persistPin, so the pin commit carries the
767
+ // added rules and the next upgrade does not read them as a human's stray work.
768
+ if (!dryRun) addWritten('.claude/settings.json');
769
+ summary.settingsDenyAdded = topUp.added;
770
+ summary.notes.push(`.claude/settings.json kept every rule it already had and GAINED ${topUp.added.length} missing deny rule(s) — an instance that took the starter before task 1003966 carried none of them, so its local agent could still read keys and session tokens: ${topUp.added.join(', ')}`);
771
+ log(` ${dryRun ? '[dry-run] would add' : '✓ added'} ${topUp.added.length} missing deny rule(s) to .claude/settings.json — every rule you wrote is kept, the file is re-indented to 2 spaces: ${topUp.added.join(', ')}`);
772
+ } else if (topUp.reason) {
773
+ // The security gap PERSISTS here, so it does not get the same quiet bullet as the
774
+ // all-clear. upgrade.js never iterates summary.notes, so this log line is the only
775
+ // channel that reaches the operator's terminal.
776
+ summary.skippedFiles.push('settings.json');
777
+ summary.settingsDenyUncheckable = topUp.reason;
778
+ summary.notes.push(`.claude/settings.json could not be checked for missing deny rules — ${topUp.reason}. It was left exactly as it is, so if it predates task 1003966 its local agent can still read keys and session tokens; compare its permissions.deny against the starter by hand, or fix the file and upgrade again.`);
779
+ log(` ! .claude/settings.json could NOT be checked for missing deny rules — ${topUp.reason}. Left untouched; its deny half may still be missing.`);
780
+ } else {
781
+ summary.skippedFiles.push('settings.json');
782
+ log(' • .claude/settings.json already present — left untouched (the instance owns it; it already carries every starter deny rule).');
783
+ }
717
784
  } else {
718
785
  if (!dryRun) {
719
786
  fs.mkdirSync(destClaude, { recursive: true });
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.962'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.963'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -885,6 +885,129 @@ t('a checkout-materialized instance keeps the session-store rule at its fixed an
885
885
  rmSync(inst, { recursive: true, force: true });
886
886
  });
887
887
 
888
+ // ---- task 1004156: the deny rules reach an instance that ALREADY has a settings.json ----
889
+ // THE DEFECT. The starter is written only when the file is ABSENT, so every instance that took
890
+ // the allow-only starter (task 1003962) kept it forever and never received task 1003966's deny
891
+ // half — its local agent could still read .env files, keys and session tokens. `bongos upgrade`
892
+ // always takes this branch: it materializes from the published package, which excludes
893
+ // .claude/settings.json on purpose, so the starter branch always meets a file already there.
894
+ // THE FIX, and the owner's call: ADD the missing deny rules and name them. Additive is the whole
895
+ // safety argument — a deny rule can only narrow what the agent may do, so appending one cannot
896
+ // widen this instance's permissions. ADR 0121 §2 already licenses "merge OR leave-and-report"
897
+ // for a pre-existing file; this implements the merge half for the one shape that is provably safe.
898
+ const topUpFixture = (settings) => {
899
+ const core = mkdtempSync(join(tmpdir(), 'w3-topup-core-'));
900
+ const inst = mkdtempSync(join(tmpdir(), 'w3-topup-inst-'));
901
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true }); // skills ship; settings.json does not
902
+ mkdirSync(join(inst, '.claude'), { recursive: true });
903
+ mkdirSync(join(inst, 'config'), { recursive: true });
904
+ writeFileSync(join(inst, 'config', 'branding.json'), JSON.stringify({ configDir: 'acme', envPrefix: 'ACME' }));
905
+ const settingsPath = join(inst, '.claude', 'settings.json');
906
+ writeFileSync(settingsPath, typeof settings === 'string' ? settings : `${JSON.stringify(settings, null, 2)}\n`);
907
+ return { core, inst, settingsPath, read: () => readFileSync(settingsPath, 'utf8'),
908
+ cleanup: () => { rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true }); } };
909
+ };
910
+
911
+ t('an instance still on the allow-only starter GAINS the deny rules on its next materialize', () => {
912
+ const f = topUpFixture({ _doc: 'the task-1003962 starter', permissions: { allow: m.STARTER_ALLOW } });
913
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
914
+ const after = JSON.parse(f.read());
915
+ assert.deepEqual(after.permissions.deny, starterDenyFor({ configDir: 'acme' }),
916
+ "every starter deny rule, pointed at THIS instance's config dir");
917
+ assert.deepEqual(after.permissions.allow, m.STARTER_ALLOW, 'its allow half is untouched');
918
+ assert.equal(after._doc, 'the task-1003962 starter', 'and every other key it carried survives');
919
+ assert.deepEqual(res.settingsDenyAdded, after.permissions.deny, 'the run names each rule it added');
920
+ assert.ok(res.writtenPaths.some((p) => p.replace(/\\/g, '/') === '.claude/settings.json'),
921
+ 'the path is reported as written, or upgrade\'s clean-tree pre-flight halts on it as human work');
922
+ f.cleanup();
923
+ });
924
+
925
+ t("an owner-edited settings.json keeps every rule of its own and only gains what's missing", () => {
926
+ const f = topUpFixture({
927
+ permissions: { allow: ['Bash(npm test:*)'], deny: ['Read(secrets/**)', 'Read(**/*.pem)'], ask: ['Bash(rm:*)'] },
928
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo mine' }] }] },
929
+ env: { MY_VAR: '1' },
930
+ });
931
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
932
+ const after = JSON.parse(f.read());
933
+ assert.deepEqual(after.permissions.allow, ['Bash(npm test:*)'], "the owner's allow list is theirs alone");
934
+ assert.deepEqual(after.permissions.ask, ['Bash(rm:*)'], 'a permissions key we do not manage is untouched');
935
+ assert.deepEqual(after.env, { MY_VAR: '1' }, 'and so is every key outside permissions');
936
+ assert.deepEqual(after.hooks.Stop[0].hooks[0], { type: 'command', command: 'echo mine' }, 'their hooks survive');
937
+ assert.deepEqual(after.permissions.deny.slice(0, 2), ['Read(secrets/**)', 'Read(**/*.pem)'],
938
+ "the owner's own deny rules keep their place at the front — appended, never reordered");
939
+ assert.equal(after.permissions.deny.filter((r) => r === 'Read(**/*.pem)').length, 1,
940
+ 'a rule they already had is not added a second time');
941
+ assert.ok(!res.settingsDenyAdded.includes('Read(**/*.pem)'), 'nor reported as added');
942
+ for (const rule of starterDenyFor({ configDir: 'acme' })) {
943
+ assert.ok(after.permissions.deny.includes(rule), `missing ${rule}`);
944
+ }
945
+ f.cleanup();
946
+ });
947
+
948
+ t('the top-up is idempotent — a second materialize changes nothing', () => {
949
+ const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
950
+ m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
951
+ const settled = f.read();
952
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
953
+ assert.deepEqual(res.settingsDenyAdded, [], 'nothing left to add');
954
+ assert.equal(f.read(), settled, 'and the file comes back byte-identical');
955
+ assert.ok(res.skippedFiles.includes('settings.json'), 'reported as left alone, as it was before');
956
+ f.cleanup();
957
+ });
958
+
959
+ t('a dry run names the missing deny rules and writes nothing', () => {
960
+ const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
961
+ const before = f.read();
962
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: true });
963
+ assert.equal(f.read(), before, 'a dry run writes nothing');
964
+ assert.deepEqual(res.settingsDenyAdded, starterDenyFor({ configDir: 'acme' }),
965
+ 'but it still names what a real run would add');
966
+ f.cleanup();
967
+ });
968
+
969
+ t("the DRY RUN does not waive settings.json from upgrade's dirty-tree pre-flight", () => {
970
+ // writtenPaths doubles as the set the pre-flight IGNORES when it looks for uncommitted work
971
+ // (task 1004122). That waiver is justified by "materialize overwrites these unconditionally,
972
+ // so whatever they hold now is discarded either way" — which is exactly NOT true of the
973
+ // top-up, the one path that PRESERVES what is there. If the preview listed settings.json, an
974
+ // owner's half-finished edit to it would stop halting the bump and would be committed and
975
+ // pushed by --commit-pin unreviewed. The real run still reports it, so persistPin commits the
976
+ // rules it added and the next upgrade does not read them as a human's stray work.
977
+ const f = topUpFixture({ permissions: { allow: m.STARTER_ALLOW } });
978
+ const preview = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: true });
979
+ const settings = (paths) => paths.some((p) => p.replace(/\\/g, '/') === '.claude/settings.json');
980
+ assert.equal(settings(preview.writtenPaths), false,
981
+ "a dirty settings.json must still halt the bump — the preview may not vouch for a file we don't overwrite");
982
+ const real = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
983
+ assert.equal(settings(real.writtenPaths), true,
984
+ 'the real run reports it, so the pin commit carries the rules it added');
985
+ f.cleanup();
986
+ });
987
+
988
+ t('a settings.json we cannot parse is left byte-for-byte alone, and said so', () => {
989
+ // Same posture task 1004023 took for the core's own file: a rewrite we cannot reason about is
990
+ // worse than the gap. The operator is told, rather than the file being quietly half-fixed.
991
+ const raw = '{ "permissions": { "allow": [] } // a comment JSON cannot hold\n}';
992
+ const f = topUpFixture(raw);
993
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
994
+ assert.equal(f.read(), raw, 'we never rewrite a file we could not parse');
995
+ assert.deepEqual(res.settingsDenyAdded, [], 'and nothing is claimed to have been added');
996
+ assert.match(res.settingsDenyUncheckable, /not valid JSON/);
997
+ assert.ok(res.notes.some((n) => n.includes('could not be checked for missing deny rules')),
998
+ 'the gap is reported rather than silently left');
999
+ f.cleanup();
1000
+ });
1001
+
1002
+ t('a deny half that is not a list is left alone rather than coerced', () => {
1003
+ const f = topUpFixture({ permissions: { allow: [], deny: 'Read(**/*.pem)' } });
1004
+ const before = f.read();
1005
+ const res = m.materializeClaude({ coreRoot: f.core, instanceDir: f.inst, dryRun: false });
1006
+ assert.equal(f.read(), before, 'an unexpected shape is never rewritten into our own');
1007
+ assert.match(res.settingsDenyUncheckable, /permissions\.deny/);
1008
+ f.cleanup();
1009
+ });
1010
+
888
1011
  // task 1004012 — the other source: a CHECKOUT core root does carry a settings.json, and the
889
1012
  // instance used to receive its allow-list verbatim, naming scripts its skills no longer run.
890
1013
  t('a checkout-materialized instance gets allow-rules in the bongos form, end to end', () => {
@@ -903,16 +1026,25 @@ t('a checkout-materialized instance gets allow-rules in the bongos form, end to
903
1026
  rmSync(inst, { recursive: true, force: true });
904
1027
  });
905
1028
 
906
- t("an instance's OWN settings.json is never clobbered", () => {
1029
+ // SUPERSEDED BY task 1004156, deliberately. This test used to assert the file came back
1030
+ // BYTE-IDENTICAL. That spelling of "never clobbered" also froze out the deny rules — an instance
1031
+ // on the allow-only starter could never receive them (task 1003966's fix reached only instances
1032
+ // materialized after it). The contract the project actually wants is the strictly stronger one
1033
+ // asserted here: nothing the instance wrote is removed, reordered or rewritten — a deny rule may
1034
+ // be APPENDED, and that can only ever narrow what the local agent may do.
1035
+ t("an instance's OWN settings.json keeps every rule it has — only deny rules are added", () => {
907
1036
  const core = mkdtempSync(join(tmpdir(), 'w3-keep-core-'));
908
1037
  const inst = mkdtempSync(join(tmpdir(), 'w3-keep-inst-'));
909
1038
  mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
910
1039
  mkdirSync(join(inst, '.claude'), { recursive: true });
911
- const mine = JSON.stringify({ permissions: { allow: ['Bash(echo hello:*)'] } }, null, 2);
912
- writeFileSync(join(inst, '.claude', 'settings.json'), mine);
1040
+ const mine = { permissions: { allow: ['Bash(echo hello:*)'] } };
1041
+ writeFileSync(join(inst, '.claude', 'settings.json'), JSON.stringify(mine, null, 2));
913
1042
  m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
914
- assert.equal(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'), mine,
1043
+ const after = JSON.parse(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'));
1044
+ assert.deepEqual(after.permissions.allow, mine.permissions.allow,
915
1045
  'an upgrade must never overwrite permissions the instance has grown');
1046
+ assert.deepEqual(Object.keys(after), Object.keys(mine), 'and it grows no key of its own');
1047
+ assert.ok(after.permissions.deny.length > 0, 'the deny half it never had does arrive');
916
1048
  rmSync(core, { recursive: true, force: true });
917
1049
  rmSync(inst, { recursive: true, force: true });
918
1050
  });