pi-daddy 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +12 -7
  3. package/contracts/ledger/v3/fixtures/capability-decision.json +3 -4
  4. package/contracts/ledger/v3/fixtures/check-receipt.json +3 -4
  5. package/contracts/ledger/v3/fixtures/child-lifecycle.json +3 -4
  6. package/contracts/ledger/v3/fixtures/workflow-fact.json +3 -4
  7. package/contracts/ledger/v3/fixtures/workspace-lease.json +3 -4
  8. package/contracts/ledger/v3/ledger-event.schema.json +38 -13
  9. package/dist/check-runner.d.ts.map +1 -1
  10. package/dist/check-runner.js +161 -149
  11. package/dist/check-runner.js.map +1 -1
  12. package/dist/correlation.d.ts +15 -4
  13. package/dist/correlation.d.ts.map +1 -1
  14. package/dist/correlation.js +40 -2
  15. package/dist/correlation.js.map +1 -1
  16. package/dist/delegation-approval.d.ts +2 -2
  17. package/dist/delegation-approval.d.ts.map +1 -1
  18. package/dist/delegation-approval.js +2 -0
  19. package/dist/delegation-approval.js.map +1 -1
  20. package/dist/finalization.d.ts +15 -0
  21. package/dist/finalization.d.ts.map +1 -0
  22. package/dist/finalization.js +65 -0
  23. package/dist/finalization.js.map +1 -0
  24. package/dist/git-identity.d.ts.map +1 -1
  25. package/dist/git-identity.js +18 -15
  26. package/dist/git-identity.js.map +1 -1
  27. package/dist/grant-store.d.ts +13 -0
  28. package/dist/grant-store.d.ts.map +1 -1
  29. package/dist/grant-store.js +48 -25
  30. package/dist/grant-store.js.map +1 -1
  31. package/dist/init.d.ts.map +1 -1
  32. package/dist/init.js +2 -6
  33. package/dist/init.js.map +1 -1
  34. package/dist/model-preflight.d.ts +8 -0
  35. package/dist/model-preflight.d.ts.map +1 -0
  36. package/dist/model-preflight.js +20 -0
  37. package/dist/model-preflight.js.map +1 -0
  38. package/dist/refusals.d.ts +1 -1
  39. package/dist/refusals.d.ts.map +1 -1
  40. package/dist/refusals.js +2 -0
  41. package/dist/refusals.js.map +1 -1
  42. package/dist/run-herdr.d.ts.map +1 -1
  43. package/dist/run-herdr.js +6 -5
  44. package/dist/run-herdr.js.map +1 -1
  45. package/extensions/chain-plan.ts +16 -0
  46. package/extensions/correlation-shape.ts +39 -0
  47. package/extensions/delegate-chain.ts +9 -14
  48. package/extensions/delegation.ts +2 -24
  49. package/extensions/execute-child.ts +8 -1
  50. package/extensions/grant-store-refusal.ts +36 -0
  51. package/extensions/grants.ts +6 -0
  52. package/extensions/run-delegation.ts +21 -2
  53. package/extensions/session.ts +15 -5
  54. package/extensions/stored-grant-session.ts +33 -0
  55. package/package.json +3 -1
  56. package/src/check-runner.ts +56 -44
  57. package/src/correlation.ts +55 -6
  58. package/src/delegation-approval.ts +4 -2
  59. package/src/finalization.ts +79 -0
  60. package/src/git-identity.ts +20 -16
  61. package/src/grant-store.ts +55 -25
  62. package/src/init.ts +5 -5
  63. package/src/model-preflight.ts +32 -0
  64. package/src/refusals.ts +2 -0
  65. package/src/run-herdr.ts +6 -4
package/CHANGELOG.md CHANGED
@@ -12,12 +12,31 @@ the record of how the package got here and are worth keeping; they are not worth
12
12
  > the record of how the package arrived at what it does, and because the reasoning behind each one is
13
13
  > usually the clearest statement of why the current behaviour is what it is.
14
14
 
15
+ ## 0.22.0 — fail-closed audit follow-ups (2026-09-04)
16
+
17
+ - **BREAKING — invalid project stores now fail closed.** Malformed, unsupported, unreadable and wrong-directory
18
+ stores never restore the ungoverned wildcard; `GRANT_STORE_INVALID` records the safety stop. Repair the store
19
+ or deliberately rerun `/grants init` rather than relying on invalid state being treated as absent.
20
+ - **BREAKING — explicit delegation models are resolved before any side effect.** Models absent from pi's session
21
+ catalogue refuse as `MODEL_UNRESOLVED`. Register custom models with pi, or deliberately set the operator
22
+ escape hatch `PI_GRANTS_ALLOW_UNRESOLVED_MODELS=1`.
23
+ - **BREAKING — correlation inputs are narrower.** `schema_version`, when present, must be exactly `"1.0"`, and
24
+ `assurance_scope` must use the closed entire-run/selectors union instead of arbitrary JSON. Strict ledger-v3
25
+ consumers must re-pin to the regenerated schema and fixtures, which also add `GRANT_STORE_INVALID` and
26
+ `MODEL_UNRESOLVED` to the refusal enum.
27
+ - Supplied `tree_sha` and `last_change_seq` now narrow exact approval reuse. `@types/node >=22` is declared as
28
+ a peer because the public declarations use `NodeJS.*`.
29
+ - Primary operation errors are preserved when Herdr, named-check, Git-index, or init cleanup also fails. Every
30
+ finalizer still runs and its failures remain visible; an attached Herdr close failure still retains the
31
+ workspace writer lease.
32
+ - CI runs the model-free, Herdr-free real-pi integration tier.
33
+
15
34
  ## 0.21.1 — twenty-minute default child timeout (2026-09-02)
16
35
 
17
36
  - Raise the default per-child wall-clock limit from 600 to 1200 seconds. Explicit
18
37
  `PI_GRANTS_CHILD_TIMEOUT` values remain unchanged and inherited by descendants.
19
38
 
20
- ## 0.21.0 — one project init enables its ledger (2026-09-01)
39
+ ## 0.21.0 — one project init enables its ledger (2026-09-02)
21
40
 
22
41
  - `/grants init` now atomically persists one project choice containing both the capability grant and default
23
42
  `.pi/grants.jsonl`, then adopts both after that write succeeds. Future plain `pi` starts load both, so
package/README.md CHANGED
@@ -141,7 +141,7 @@ A delegator that legitimately holds `fabric_exec` and knowingly wants a child to
141
141
  that child. `assertNarrowing`'s `allowUniversal` flag exists but is deliberately not plumbed through; the
142
142
  first real need for that override is the evidence it should be added.
143
143
 
144
- ## The two tools
144
+ ## The three tools
145
145
 
146
146
  ```
147
147
  delegate({ task: "summarise src/", agent: "docs-writer" }) // preferred: an operator-authored definition
@@ -156,7 +156,7 @@ delegate_all({ children: [ {…}, {…}, {…} ] }) // several
156
156
  - **Spawning is itself a capability.** Grant `delegate` and the child can sub-delegate; withhold it and the
157
157
  child is a leaf — the extension is only passed to children that hold it, so the machinery isn't even
158
158
  present. `PI_GRANTS_MAX_DEPTH` remains a backstop.
159
- - **A refusal is a tool *error*, not an answer.** Both tools throw, because `AgentToolResult` has **no
159
+ - **A refusal is a tool *error*, not an answer.** All three tools throw, because `AgentToolResult` has **no
160
160
  `isError` field** — pi sets it only when `execute` throws, and a normal return is hardcoded
161
161
  `isError: false`. Until 0.5.0 the tool returned `isError: true`, which was silently discarded, so every
162
162
  refusal this package made was recorded by pi as a **successful** tool call. Found by the integration suite
@@ -593,6 +593,7 @@ everything below it.
593
593
  | `PI_GRANTS_WORKSPACE_REGISTRY` | unset | Operator-owned `{version:1, workspaces:{id:{path}}}` file, required only for workspace-routed spawns. |
594
594
  | `PI_GRANTS_WORKSPACE_LEASE_DIR` | under `$PI_CODING_AGENT_DIR/pi-daddy/` | Kernel writer locks and ownership metadata. |
595
595
  | `PI_GRANTS_CHILD_TIMEOUT` | `1200` (seconds) | Wall-clock limit for a child. Inherited by descendants — an operator preference, deliberately *not* attenuating state. |
596
+ | `PI_GRANTS_ALLOW_UNRESOLVED_MODELS` | unset | Exact `1` lets pi attempt custom model resolution; otherwise an explicit provider/id missing from pi's session catalogue refuses before lease, approval or spawn. |
596
597
  | `PI_GRANTS_FANOUT` | `8` | Per-call width and downward subtree budget; not a session-total counter. Malformed or `0` falls back to the default. |
597
598
  | `PI_GRANTS_PARENT_ID` | `d0` | Readable logical tree position; set by the parent and allowed to repeat across calls. |
598
599
  | `PI_GRANTS_EXECUTION_ID` | unset at a root | Unique governed execution occurrence; set by the parent. Lifecycle/lease joins use this, never `PI_GRANTS_PARENT_ID`. |
@@ -601,7 +602,9 @@ everything below it.
601
602
  | `PI_GRANTS_HERDR_KEEP_PANE` | unset | `1` keeps each child's pane for inspection, and no sweep closes it. Off by default: a fan-out would flood the workspace. |
602
603
  | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | pi's own variable, not ours — but it decides where stored project grants/ledger consent and persisted approvals live. |
603
604
 
604
- **A malformed value disables spawning; it never falls back to a default.** An unreadable
605
+ **A malformed value disables spawning; it never falls back to a default.** Stored grants are likewise
606
+ tri-state: only a missing file is opt-out; malformed, unsupported-version, unreadable or wrong-cwd state is a
607
+ loud empty-grant session with a `GRANT_STORE_INVALID` ledger line. An unreadable
605
608
  `PI_GRANTS_MAX_DEPTH` or `PI_GRANTS_DEPTH` yields `maxDepth: 0` and a startup warning naming the
606
609
  variable. Before 0.5.0 these were read with `parseInt`, which accepts numeric prefixes (`"2abc"` → `2`)
607
610
  and otherwise gives `NaN` — and since every comparison against `NaN` is false, a typo did not tighten the
@@ -632,8 +635,10 @@ timed out. A pending running append always lands before a terminal event. `denie
632
635
  is invisible without a record.**
633
636
 
634
637
  Workflow facts are identifier-only and mark `planned`, `observed`, or `controller_validated`; they can never
635
- claim pi-daddy enforcement. Capability/lifecycle records are the enforced class. Correlation remains a
636
- caller-declared label; fields eligible for display must use the v3 ASCII identifier grammar rather than prose.
638
+ claim pi-daddy enforcement. Capability/lifecycle records are the enforced class. Correlation remains
639
+ caller-declared: optional `schema_version` is exactly `1.0`, and `assurance_scope` is either entire-run with an
640
+ empty selector list or selectors with one or more non-empty strings. Fields eligible for display use the v3
641
+ ASCII identifier grammar rather than prose.
637
642
 
638
643
  **Privacy is a property of this file, and the boundary is exact: capability ids, counts and identifiers only
639
644
  — never prompts, task text, tool arguments or results.** Trusted `definitionDigest` and `taskDigest` values
@@ -784,9 +789,9 @@ every in-repo test passed. `npm run test:smoke` packs a tarball, installs it int
784
789
  ## Testing
785
790
 
786
791
  ```bash
787
- npm test # 719 unit tests. Fast, pure, no pi, no network.
792
+ npm test # 741 unit tests. Fast, pure, no pi, no network.
788
793
  npm run typecheck # src + extensions + tests + integration tests
789
- npm run test:integration # 45 tests against a REAL pi process. ~55s, no model tokens.
794
+ npm run test:integration # 48 tests against a REAL pi process/Herdr server, no model tokens.
790
795
  npm run test:smoke # pack/install; exercise library exports, both bins, the v2/v3 contracts,
791
796
  # bundled Herdr plugin, dashboard, and `pi-daddy init`
792
797
  PI_GRANTS_IT_MODEL=1 npm run test:integration # + 10 end-to-end tests with a real model. Costs money.
@@ -24,11 +24,10 @@
24
24
  "policy_label": "policy-contract",
25
25
  "assurance_source": "policy",
26
26
  "assurance_scope": {
27
- "kind": "changed-files",
28
- "paths": [
27
+ "type": "selectors",
28
+ "selectors": [
29
29
  "src/**"
30
- ],
31
- "include_untracked": true
30
+ ]
32
31
  },
33
32
  "activated_at": "2026-08-20T12:00:00.000Z",
34
33
  "plan_digest": "1111111111111111111111111111111111111111111111111111111111111111",
@@ -21,11 +21,10 @@
21
21
  "policy_label": "policy-contract",
22
22
  "assurance_source": "policy",
23
23
  "assurance_scope": {
24
- "kind": "changed-files",
25
- "paths": [
24
+ "type": "selectors",
25
+ "selectors": [
26
26
  "src/**"
27
- ],
28
- "include_untracked": true
27
+ ]
29
28
  },
30
29
  "activated_at": "2026-08-20T12:00:00.000Z",
31
30
  "plan_digest": "1111111111111111111111111111111111111111111111111111111111111111",
@@ -24,11 +24,10 @@
24
24
  "policy_label": "policy-contract",
25
25
  "assurance_source": "policy",
26
26
  "assurance_scope": {
27
- "kind": "changed-files",
28
- "paths": [
27
+ "type": "selectors",
28
+ "selectors": [
29
29
  "src/**"
30
- ],
31
- "include_untracked": true
30
+ ]
32
31
  },
33
32
  "activated_at": "2026-08-20T12:00:00.000Z",
34
33
  "plan_digest": "1111111111111111111111111111111111111111111111111111111111111111",
@@ -20,11 +20,10 @@
20
20
  "policy_label": "policy-contract",
21
21
  "assurance_source": "policy",
22
22
  "assurance_scope": {
23
- "kind": "changed-files",
24
- "paths": [
23
+ "type": "selectors",
24
+ "selectors": [
25
25
  "src/**"
26
- ],
27
- "include_untracked": true
26
+ ]
28
27
  },
29
28
  "activated_at": "2026-08-20T12:00:00.000Z",
30
29
  "plan_digest": "1111111111111111111111111111111111111111111111111111111111111111",
@@ -22,11 +22,10 @@
22
22
  "policy_label": "policy-contract",
23
23
  "assurance_source": "policy",
24
24
  "assurance_scope": {
25
- "kind": "changed-files",
26
- "paths": [
25
+ "type": "selectors",
26
+ "selectors": [
27
27
  "src/**"
28
- ],
29
- "include_untracked": true
28
+ ]
30
29
  },
31
30
  "activated_at": "2026-08-20T12:00:00.000Z",
32
31
  "plan_digest": "1111111111111111111111111111111111111111111111111111111111111111",
@@ -28,10 +28,10 @@
28
28
  },
29
29
  "correlation": {
30
30
  "type": "object",
31
- "description": "Opaque, non-authoritative controller metadata. String and aggregate byte limits are additionally enforced by the runtime and cannot be represented exactly in JSON Schema.",
31
+ "description": "Non-authoritative controller metadata under the pinned 1.0 contract. String and aggregate byte limits are additionally enforced by the runtime and cannot be represented exactly in JSON Schema.",
32
32
  "properties": {
33
33
  "schema_version": {
34
- "$ref": "#/$defs/ledgerCorrelationIdentifier"
34
+ "const": "1.0"
35
35
  },
36
36
  "run_id": {
37
37
  "$ref": "#/$defs/ledgerCorrelationIdentifier"
@@ -63,19 +63,42 @@
63
63
  "assurance_scope": {
64
64
  "oneOf": [
65
65
  {
66
- "type": "object"
67
- },
68
- {
69
- "type": "array"
70
- },
71
- {
72
- "type": "string"
73
- },
74
- {
75
- "type": "number"
66
+ "type": "object",
67
+ "properties": {
68
+ "type": {
69
+ "const": "entire-run"
70
+ },
71
+ "selectors": {
72
+ "type": "array",
73
+ "maxItems": 0
74
+ }
75
+ },
76
+ "required": [
77
+ "type",
78
+ "selectors"
79
+ ],
80
+ "additionalProperties": false
76
81
  },
77
82
  {
78
- "type": "boolean"
83
+ "type": "object",
84
+ "properties": {
85
+ "type": {
86
+ "const": "selectors"
87
+ },
88
+ "selectors": {
89
+ "type": "array",
90
+ "minItems": 1,
91
+ "items": {
92
+ "type": "string",
93
+ "minLength": 1
94
+ }
95
+ }
96
+ },
97
+ "required": [
98
+ "type",
99
+ "selectors"
100
+ ],
101
+ "additionalProperties": false
79
102
  }
80
103
  ]
81
104
  },
@@ -150,6 +173,8 @@
150
173
  "DEPTH_EXCEEDED",
151
174
  "FANOUT_EXCEEDED",
152
175
  "EXECUTOR_UNAVAILABLE",
176
+ "MODEL_UNRESOLVED",
177
+ "GRANT_STORE_INVALID",
153
178
  "CHILD_TIMED_OUT",
154
179
  "CHILD_CANCELLED",
155
180
  "CHILD_EXIT_NONZERO",
@@ -1 +1 @@
1
- {"version":3,"file":"check-runner.d.ts","sourceRoot":"","sources":["../src/check-runner.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwB,KAAK,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AASlF,OAAO,EAGL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EAGrB,MAAM,gBAAgB,CAAC;AAIxB,MAAM,WAAW,eAAe;IAC9B,gGAAgG;IAChG,UAAU,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,8CAA8C;IAC9C,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,eAAe,CAAC;CACpC;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,CAAC,CAAC;IACX,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,YAAY;IAC3B,cAAc,EAAE,KAAK,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,eAAe,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,mBAAmB,CAAC;CACnC;AAKD,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,eAAe,EAC3B,SAAS,GAAE,MAAM,CAAC,UAAwB,GACzC,MAAM,CAAC,UAAU,CAWnB;AAkCD,wBAAsB,aAAa,CAAC,KAAK,EAAE;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC;IACxB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC;IACV,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAC9B,OAAO,EAAE,YAAY,CAAC;CACvB,CAAC,CA8ND"}
1
+ {"version":3,"file":"check-runner.d.ts","sourceRoot":"","sources":["../src/check-runner.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwB,KAAK,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAUlF,OAAO,EAGL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EAGrB,MAAM,gBAAgB,CAAC;AAIxB,MAAM,WAAW,eAAe;IAC9B,gGAAgG;IAChG,UAAU,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,8CAA8C;IAC9C,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,eAAe,CAAC;CACpC;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,CAAC,CAAC;IACX,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,YAAY;IAC3B,cAAc,EAAE,KAAK,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,eAAe,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,mBAAmB,CAAC;CACnC;AAKD,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,eAAe,EAC3B,SAAS,GAAE,MAAM,CAAC,UAAwB,GACzC,MAAM,CAAC,UAAU,CAWnB;AAkCD,wBAAsB,aAAa,CAAC,KAAK,EAAE;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC;IACxB,SAAS,EAAE,kBAAkB,CAAC;IAC9B,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,GAAG,OAAO,CAAC;IACV,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;IAC9B,OAAO,EAAE,YAAY,CAAC;CACvB,CAAC,CAyOD"}
@@ -3,6 +3,7 @@ import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, isAbsolute, join } from "node:path";
5
5
  import { normaliseCorrelation } from "./correlation.js";
6
+ import { runWithFinalizers } from "./finalization.js";
6
7
  import { appendLedgerEvent, buildCheckReceiptLedgerEvent, buildWorkspaceLeaseEvent, } from "./ledger.js";
7
8
  import { computeGitCandidateIdentity } from "./git-identity.js";
8
9
  import { runChild } from "./run-child.js";
@@ -77,173 +78,184 @@ export async function runNamedCheck(input) {
77
78
  /** Failures of best-effort RECORDS, reported alongside whatever actually happened — never instead of it. */
78
79
  const notes = [];
79
80
  let releaseReason = "failed";
80
- try {
81
+ return runWithFinalizers(async () => {
81
82
  try {
82
- lease = await acquireWorkspaceLease({
83
- workspace: input.workspace, access: leaseAccess, leaseDir: input.leaseDir ?? defaultWorkspaceLeaseDir(),
84
- ownerId, signal: input.signal,
83
+ try {
84
+ lease = await acquireWorkspaceLease({
85
+ workspace: input.workspace, access: leaseAccess, leaseDir: input.leaseDir ?? defaultWorkspaceLeaseDir(),
86
+ ownerId, signal: input.signal,
87
+ });
88
+ }
89
+ catch (error) {
90
+ if (input.ledgerPath)
91
+ await appendLedgerEvent({ path: input.ledgerPath, strict: true }, buildWorkspaceLeaseEvent({
92
+ executionId, parentExecutionId,
93
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
94
+ outcome: "refused",
95
+ correlation, now: new Date(),
96
+ ...(error instanceof GovernanceRefusal
97
+ ? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
98
+ : {}),
99
+ }));
100
+ throw error;
101
+ }
102
+ if (input.ledgerPath) {
103
+ try {
104
+ await appendLedgerEvent({ path: input.ledgerPath, strict: true }, buildWorkspaceLeaseEvent({
105
+ executionId, parentExecutionId,
106
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
107
+ outcome: leaseAcquisitionOutcome(leaseAccess, lease.recovered), recovered: lease.recovered,
108
+ correlation, now: new Date(),
109
+ }));
110
+ }
111
+ catch (error) {
112
+ await lease.release("ledger-failed");
113
+ lease = undefined;
114
+ throw error;
115
+ }
116
+ }
117
+ const before = await computeGitCandidateIdentity(input.workspace);
118
+ for (const [name, supplied, actual] of [
119
+ ["head_sha", correlation?.head_sha, before.headSha], ["tree_sha", correlation?.tree_sha, before.treeSha],
120
+ ]) {
121
+ if (supplied && supplied !== actual)
122
+ throw new GovernanceRefusal(refusal("CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} ${name} does not match the validated workspace`, { check_id: input.checkId, supplied, actual }));
123
+ }
124
+ const executableBytes = await readFile(executable);
125
+ const executableSha256 = createHash("sha256").update(executableBytes).digest("hex");
126
+ stagedDir = await mkdtemp(join(tmpdir(), "pi-daddy-check-exec-"));
127
+ const stagedExecutable = join(stagedDir, basename(executable));
128
+ await writeFile(stagedExecutable, executableBytes, { mode: 0o700 });
129
+ await chmod(stagedExecutable, 0o700);
130
+ const leaseAbort = new AbortController();
131
+ let leaseLost = false;
132
+ lease.lost.then(() => { leaseLost = true; leaseAbort.abort(); });
133
+ const checkSignal = input.signal ? AbortSignal.any([input.signal, leaseAbort.signal]) : leaseAbort.signal;
134
+ const started = new Date();
135
+ const result = await runChild({
136
+ command: "setpriv", args: ["--pdeathsig", "KILL", "--", stagedExecutable, ...definition.argv],
137
+ env: buildCheckEnvironment(definition, input.inheritedEnv), cwd: input.workspace.root,
138
+ signal: checkSignal, timeoutMs: definition.timeout_ms, maxOutputBytes: definition.max_output_bytes,
139
+ onSpawn: (pid) => lease.attachProcess(pid),
85
140
  });
86
- }
87
- catch (error) {
141
+ const ended = new Date();
142
+ releaseReason = input.signal?.aborted ? "cancelled" : result.timedOut ? "timeout" : result.aborted ? "failed" : "completed";
143
+ // Every throw below re-stamps this. Without that, a check refused for CHECK_IDENTITY_MISMATCH still
144
+ // wrote `released / completed` from the `finally` — the LAST lease event for that owner — so the trail
145
+ // still read like a clean run and `verifyLedger` still counted a clean release. The R-114 fix added a
146
+ // refusal line beside the misleading one instead of correcting it.
147
+ if (result.spawnError)
148
+ throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} could not start its constrained executor`, { check_id: input.checkId }));
149
+ if (leaseLost)
150
+ throw new GovernanceRefusal(refusal("WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease while running`, { check_id: input.checkId }));
151
+ const after = await computeGitCandidateIdentity(input.workspace);
152
+ if (leaseLost)
153
+ throw new GovernanceRefusal(refusal("WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`, { check_id: input.checkId }));
154
+ if (before.headSha !== after.headSha || before.treeSha !== after.treeSha) {
155
+ throw new GovernanceRefusal(refusal("CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} workspace changed while it ran`, { check_id: input.checkId }));
156
+ }
157
+ // The candidate is now measured and the process is dead. End the lease before writing the receipt so
158
+ // lease loss cannot race a blocked ledger append and leave behind evidence from an unprotected run.
159
+ // Record what release actually DID. This asserted `released` unconditionally and threw the outcome
160
+ // away, so the evidence path reproduced the very defect the outcome union was added to expose: a lost
161
+ // or unrecorded handover ledgered as a clean one, with `lease = undefined` stopping the `finally` from
162
+ // ever noticing (R-100/R-103, found while reviewing the fix for R-99).
163
+ const releaseOutcome = await lease.release(releaseReason);
164
+ lease = undefined;
165
+ if (leaseLost)
166
+ throw new GovernanceRefusal(refusal("WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`, { check_id: input.checkId }));
88
167
  if (input.ledgerPath)
89
168
  await appendLedgerEvent({ path: input.ledgerPath, strict: true }, buildWorkspaceLeaseEvent({
90
169
  executionId, parentExecutionId,
91
170
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
92
- outcome: "refused",
93
- correlation, now: new Date(),
94
- ...(error instanceof GovernanceRefusal
95
- ? { refusal: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } }
96
- : {}),
171
+ outcome: leaseReleaseLedgerOutcome(releaseOutcome, releaseReason), releaseReason, correlation, now: new Date(),
97
172
  }));
98
- throw error;
99
- }
100
- if (input.ledgerPath) {
101
- try {
102
- await appendLedgerEvent({ path: input.ledgerPath, strict: true }, buildWorkspaceLeaseEvent({
173
+ const body = {
174
+ schema_version: "1.0", check_id: input.checkId, executable, executable_sha256: executableSha256,
175
+ argv: [...definition.argv], argv_sha256: argvSha256, cwd: input.workspace.root, cwd_sha256: cwdSha256,
176
+ started_at: started.toISOString(), ended_at: ended.toISOString(), exit_code: result.code,
177
+ signal: result.signal ?? null, timed_out: result.timedOut, aborted: result.aborted, truncated: result.truncated,
178
+ output_sha256: createHash("sha256").update(result.text, "utf8").digest("hex"),
179
+ workspace_id: input.workspace.workspaceId, workspace_access: access,
180
+ head_sha: before.headSha, tree_sha: before.treeSha, ...(correlation ? { correlation } : {}),
181
+ };
182
+ const receipt = { receipt_id: receiptId(body), ...body };
183
+ if (input.ledgerPath) {
184
+ await appendLedgerEvent({ path: input.ledgerPath, strict: true }, buildCheckReceiptLedgerEvent({
103
185
  executionId, parentExecutionId,
104
- childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
105
- outcome: leaseAcquisitionOutcome(leaseAccess, lease.recovered), recovered: lease.recovered,
106
- correlation, now: new Date(),
186
+ childId: ownerId, receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId,
187
+ checkId: input.checkId, treeSha: receipt.tree_sha, correlation, now: ended,
107
188
  }));
108
189
  }
109
- catch (error) {
110
- await lease.release("ledger-failed");
111
- lease = undefined;
112
- throw error;
113
- }
114
- }
115
- const before = await computeGitCandidateIdentity(input.workspace);
116
- for (const [name, supplied, actual] of [
117
- ["head_sha", correlation?.head_sha, before.headSha], ["tree_sha", correlation?.tree_sha, before.treeSha],
118
- ]) {
119
- if (supplied && supplied !== actual)
120
- throw new GovernanceRefusal(refusal("CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} ${name} does not match the validated workspace`, { check_id: input.checkId, supplied, actual }));
121
- }
122
- const executableBytes = await readFile(executable);
123
- const executableSha256 = createHash("sha256").update(executableBytes).digest("hex");
124
- stagedDir = await mkdtemp(join(tmpdir(), "pi-daddy-check-exec-"));
125
- const stagedExecutable = join(stagedDir, basename(executable));
126
- await writeFile(stagedExecutable, executableBytes, { mode: 0o700 });
127
- await chmod(stagedExecutable, 0o700);
128
- const leaseAbort = new AbortController();
129
- let leaseLost = false;
130
- lease.lost.then(() => { leaseLost = true; leaseAbort.abort(); });
131
- const checkSignal = input.signal ? AbortSignal.any([input.signal, leaseAbort.signal]) : leaseAbort.signal;
132
- const started = new Date();
133
- const result = await runChild({
134
- command: "setpriv", args: ["--pdeathsig", "KILL", "--", stagedExecutable, ...definition.argv],
135
- env: buildCheckEnvironment(definition, input.inheritedEnv), cwd: input.workspace.root,
136
- signal: checkSignal, timeoutMs: definition.timeout_ms, maxOutputBytes: definition.max_output_bytes,
137
- onSpawn: (pid) => lease.attachProcess(pid),
138
- });
139
- const ended = new Date();
140
- releaseReason = input.signal?.aborted ? "cancelled" : result.timedOut ? "timeout" : result.aborted ? "failed" : "completed";
141
- // Every throw below re-stamps this. Without that, a check refused for CHECK_IDENTITY_MISMATCH still
142
- // wrote `released / completed` from the `finally` — the LAST lease event for that owner — so the trail
143
- // still read like a clean run and `verifyLedger` still counted a clean release. The R-114 fix added a
144
- // refusal line beside the misleading one instead of correcting it.
145
- if (result.spawnError)
146
- throw new GovernanceRefusal(refusal("EXECUTOR_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} could not start its constrained executor`, { check_id: input.checkId }));
147
- if (leaseLost)
148
- throw new GovernanceRefusal(refusal("WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease while running`, { check_id: input.checkId }));
149
- const after = await computeGitCandidateIdentity(input.workspace);
150
- if (leaseLost)
151
- throw new GovernanceRefusal(refusal("WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`, { check_id: input.checkId }));
152
- if (before.headSha !== after.headSha || before.treeSha !== after.treeSha) {
153
- throw new GovernanceRefusal(refusal("CHECK_IDENTITY_MISMATCH", `check ${JSON.stringify(input.checkId)} workspace changed while it ran`, { check_id: input.checkId }));
154
- }
155
- // The candidate is now measured and the process is dead. End the lease before writing the receipt so
156
- // lease loss cannot race a blocked ledger append and leave behind evidence from an unprotected run.
157
- // Record what release actually DID. This asserted `released` unconditionally and threw the outcome
158
- // away, so the evidence path reproduced the very defect the outcome union was added to expose: a lost
159
- // or unrecorded handover ledgered as a clean one, with `lease = undefined` stopping the `finally` from
160
- // ever noticing (R-100/R-103, found while reviewing the fix for R-99).
161
- const releaseOutcome = await lease.release(releaseReason);
162
- lease = undefined;
163
- if (leaseLost)
164
- throw new GovernanceRefusal(refusal("WORKSPACE_LEASE_STALE", `check ${JSON.stringify(input.checkId)} lost its workspace lease before receipt`, { check_id: input.checkId }));
165
- if (input.ledgerPath)
166
- await appendLedgerEvent({ path: input.ledgerPath, strict: true }, buildWorkspaceLeaseEvent({
167
- executionId, parentExecutionId,
168
- childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
169
- outcome: leaseReleaseLedgerOutcome(releaseOutcome, releaseReason), releaseReason, correlation, now: new Date(),
170
- }));
171
- const body = {
172
- schema_version: "1.0", check_id: input.checkId, executable, executable_sha256: executableSha256,
173
- argv: [...definition.argv], argv_sha256: argvSha256, cwd: input.workspace.root, cwd_sha256: cwdSha256,
174
- started_at: started.toISOString(), ended_at: ended.toISOString(), exit_code: result.code,
175
- signal: result.signal ?? null, timed_out: result.timedOut, aborted: result.aborted, truncated: result.truncated,
176
- output_sha256: createHash("sha256").update(result.text, "utf8").digest("hex"),
177
- workspace_id: input.workspace.workspaceId, workspace_access: access,
178
- head_sha: before.headSha, tree_sha: before.treeSha, ...(correlation ? { correlation } : {}),
179
- };
180
- const receipt = { receipt_id: receiptId(body), ...body };
181
- if (input.ledgerPath) {
182
- await appendLedgerEvent({ path: input.ledgerPath, strict: true }, buildCheckReceiptLedgerEvent({
183
- executionId, parentExecutionId,
184
- childId: ownerId, receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId,
185
- checkId: input.checkId, treeSha: receipt.tree_sha, correlation, now: ended,
186
- }));
187
- }
188
- return { output: result.text, exitCode: result.code, signal: result.signal ?? null, receipt };
189
- }
190
- catch (error) {
191
- // The run did not complete, so the release must not be recorded as if it had. Without this the
192
- // `finally` wrote `released / completed` as the LAST lease event for a refused check, so the trail
193
- // still read like a clean run and `verifyLedger` still counted a clean release — the R-114 fix put a
194
- // refusal line beside the misleading one instead of correcting it.
195
- if (releaseReason === "completed")
196
- releaseReason = "refused";
197
- // The single most important negative result this feature can produce — CHECK_IDENTITY_MISMATCH, "the
198
- // workspace changed while the check ran" — used to leave NO ledger evidence at all, while the
199
- // `finally` recorded `released, reason: completed`, so the trail read like a clean run (R-114).
200
- if (input.ledgerPath) {
201
- await appendLedgerEvent({
202
- path: input.ledgerPath,
203
- // Best effort, and loud: this records a failure, so it must not replace the failure. A strict
204
- // append that threw here would substitute "ledger write failed" for the evidence-integrity
205
- // refusal that actually happened (R-115).
206
- strict: false,
207
- // NOT empty. `onFailure` exists because a non-strict append is a choice not to fail closed and
208
- // never a choice to be silent — the comment above says "loud", and an empty callback is the
209
- // definition of swallowing. This guards the evidence record itself, so losing it silently is
210
- // how a mutated workspace came to leave a trail that read like a clean run.
211
- onFailure: (cause) => { notes.push(`check refusal record failed: ${String(cause)}`); },
212
- }, buildWorkspaceLeaseEvent({
213
- executionId, parentExecutionId,
214
- childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
215
- outcome: "refused",
216
- refusal: error instanceof GovernanceRefusal
217
- ? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
218
- : refusal("CHECK_IDENTITY_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} failed (${String(error)})`),
219
- correlation, now: new Date(),
220
- }));
190
+ return { output: result.text, exitCode: result.code, signal: result.signal ?? null, receipt };
221
191
  }
222
- throw error;
223
- }
224
- finally {
225
- if (stagedDir)
226
- await rm(stagedDir, { recursive: true, force: true });
227
- if (lease) {
228
- // `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
229
- // above: a `finally` that throws destroys whatever the caller was already returning or raising.
230
- const outcome = await lease.release(releaseReason);
231
- if (input.ledgerPath)
192
+ catch (error) {
193
+ // The run did not complete, so the release must not be recorded as if it had. Without this the
194
+ // `finally` wrote `released / completed` as the LAST lease event for a refused check, so the trail
195
+ // still read like a clean run and `verifyLedger` still counted a clean release — the R-114 fix put a
196
+ // refusal line beside the misleading one instead of correcting it.
197
+ if (releaseReason === "completed")
198
+ releaseReason = "refused";
199
+ // The single most important negative result this feature can produce CHECK_IDENTITY_MISMATCH, "the
200
+ // workspace changed while the check ran" — used to leave NO ledger evidence at all, while the
201
+ // `finally` recorded `released, reason: completed`, so the trail read like a clean run (R-114).
202
+ if (input.ledgerPath) {
232
203
  await appendLedgerEvent({
233
204
  path: input.ledgerPath,
205
+ // Best effort, and loud: this records a failure, so it must not replace the failure. A strict
206
+ // append that threw here would substitute "ledger write failed" for the evidence-integrity
207
+ // refusal that actually happened (R-115).
234
208
  strict: false,
235
- onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
209
+ // NOT empty. `onFailure` exists because a non-strict append is a choice not to fail closed and
210
+ // never a choice to be silent — the comment above says "loud", and an empty callback is the
211
+ // definition of swallowing. This guards the evidence record itself, so losing it silently is
212
+ // how a mutated workspace came to leave a trail that read like a clean run.
213
+ onFailure: (cause) => { notes.push(`check refusal record failed: ${String(cause)}`); },
236
214
  }, buildWorkspaceLeaseEvent({
237
215
  executionId, parentExecutionId,
238
216
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
239
- outcome: outcome === "lost"
240
- ? "lost"
241
- : outcome === "released-unrecorded"
242
- ? "released-unrecorded"
243
- : releaseReason === "timeout" ? "timeout" : "released",
244
- releaseReason, correlation, now: new Date(),
217
+ outcome: "refused",
218
+ refusal: error instanceof GovernanceRefusal
219
+ ? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
220
+ : refusal("CHECK_IDENTITY_UNAVAILABLE", `check ${JSON.stringify(input.checkId)} failed (${String(error)})`),
221
+ correlation, now: new Date(),
245
222
  }));
223
+ }
224
+ throw error;
246
225
  }
247
- }
226
+ }, [
227
+ {
228
+ label: "staged check cleanup failed",
229
+ run: async () => {
230
+ if (stagedDir)
231
+ await rm(stagedDir, { recursive: true, force: true });
232
+ },
233
+ },
234
+ {
235
+ label: "workspace lease finalizer failed",
236
+ run: async () => {
237
+ if (!lease)
238
+ return;
239
+ // `release()` cannot throw (R-99), and the record of it is best-effort for the same reason as
240
+ // above: a finalizer must not destroy whatever the caller was already returning or raising.
241
+ const outcome = await lease.release(releaseReason);
242
+ if (input.ledgerPath)
243
+ await appendLedgerEvent({
244
+ path: input.ledgerPath,
245
+ strict: false,
246
+ onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
247
+ }, buildWorkspaceLeaseEvent({
248
+ executionId, parentExecutionId,
249
+ childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
250
+ outcome: outcome === "lost"
251
+ ? "lost"
252
+ : outcome === "released-unrecorded"
253
+ ? "released-unrecorded"
254
+ : releaseReason === "timeout" ? "timeout" : "released",
255
+ releaseReason, correlation, now: new Date(),
256
+ }));
257
+ },
258
+ },
259
+ ]);
248
260
  }
249
261
  //# sourceMappingURL=check-runner.js.map