pi-daddy 0.18.1 → 0.19.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/CHANGELOG.md +136 -0
- package/README.md +46 -2
- package/contracts/ledger/v2/README.md +59 -0
- package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
- package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
- package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
- package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
- package/contracts/ledger/v2/ledger-event.schema.json +633 -0
- package/dist/approval-prompt.d.ts +2 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +9 -0
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval.d.ts +4 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +4 -0
- package/dist/approval.js.map +1 -1
- package/dist/capabilities.d.ts +90 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +114 -3
- package/dist/capabilities.js.map +1 -1
- package/dist/catalog.d.ts +15 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +54 -3
- package/dist/catalog.js.map +1 -1
- package/dist/check-runner.d.ts.map +1 -1
- package/dist/check-runner.js +5 -7
- package/dist/check-runner.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +7 -1
- package/dist/definitions.js.map +1 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +31 -4
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts.map +1 -1
- package/dist/delegation-approval.js +37 -12
- package/dist/delegation-approval.js.map +1 -1
- package/dist/executor.d.ts +2 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +1 -0
- package/dist/executor.js.map +1 -1
- package/dist/grant-env.d.ts +2 -0
- package/dist/grant-env.d.ts.map +1 -1
- package/dist/grant-env.js +26 -3
- package/dist/grant-env.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +13 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +35 -2
- package/dist/init.js.map +1 -1
- package/dist/lease-helper.d.ts +58 -0
- package/dist/lease-helper.d.ts.map +1 -0
- package/dist/lease-helper.js +94 -0
- package/dist/lease-helper.js.map +1 -0
- package/dist/lease-record.d.ts +15 -3
- package/dist/lease-record.d.ts.map +1 -1
- package/dist/lease-record.js.map +1 -1
- package/dist/ledger-events.d.ts +35 -14
- package/dist/ledger-events.d.ts.map +1 -1
- package/dist/ledger-events.js +41 -0
- package/dist/ledger-events.js.map +1 -1
- package/dist/ledger.d.ts +10 -5
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +13 -3
- package/dist/ledger.js.map +1 -1
- package/dist/propagation.d.ts +16 -0
- package/dist/propagation.d.ts.map +1 -1
- package/dist/propagation.js +20 -1
- package/dist/propagation.js.map +1 -1
- package/dist/refusals.d.ts +1 -1
- package/dist/refusals.d.ts.map +1 -1
- package/dist/refusals.js +1 -0
- package/dist/refusals.js.map +1 -1
- package/dist/resolve.d.ts +10 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +27 -3
- package/dist/resolve.js.map +1 -1
- package/dist/routing-authority.d.ts +71 -0
- package/dist/routing-authority.d.ts.map +1 -0
- package/dist/routing-authority.js +100 -0
- package/dist/routing-authority.js.map +1 -0
- package/dist/skill-packages.d.ts +11 -5
- package/dist/skill-packages.d.ts.map +1 -1
- package/dist/skill-packages.js +20 -11
- package/dist/skill-packages.js.map +1 -1
- package/dist/workspace-lease.d.ts +15 -3
- package/dist/workspace-lease.d.ts.map +1 -1
- package/dist/workspace-lease.js +81 -24
- package/dist/workspace-lease.js.map +1 -1
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +142 -5
- package/dist/workspace.js.map +1 -1
- package/extensions/delegation.ts +7 -1
- package/extensions/grants-command.ts +11 -1
- package/extensions/grants.ts +6 -1
- package/extensions/init-command.ts +33 -2
- package/extensions/session-report.ts +13 -20
- package/extensions/session.ts +9 -1
- package/extensions/workspace-runtime.ts +34 -4
- package/package.json +8 -3
- package/src/approval-prompt.ts +2 -1
- package/src/approval.ts +4 -2
- package/src/capabilities.ts +120 -4
- package/src/catalog.ts +62 -4
- package/src/check-runner.ts +8 -8
- package/src/cli.ts +24 -1
- package/src/definitions.ts +7 -1
- package/src/delegate.ts +41 -4
- package/src/delegation-approval.ts +37 -12
- package/src/executor.ts +2 -1
- package/src/grant-env.ts +39 -7
- package/src/index.ts +1 -0
- package/src/init.ts +40 -2
- package/src/lease-helper.ts +97 -0
- package/src/lease-record.ts +15 -3
- package/src/ledger-events.ts +66 -22
- package/src/ledger.ts +30 -6
- package/src/propagation.ts +21 -1
- package/src/refusals.ts +1 -0
- package/src/resolve.ts +29 -3
- package/src/routing-authority.ts +121 -0
- package/src/skill-packages.ts +20 -13
- package/src/workspace-lease.ts +84 -24
- package/src/workspace.ts +179 -6
package/src/ledger.ts
CHANGED
|
@@ -34,11 +34,17 @@ import type { StructuredRefusal } from "./refusals.ts";
|
|
|
34
34
|
import type { RuntimeLedgerEvent } from "./ledger-events.ts";
|
|
35
35
|
|
|
36
36
|
export const LEDGER_VERSION = 2 as const;
|
|
37
|
+
export const LEDGER_EVENT_KINDS = [
|
|
38
|
+
"capability_decision", "workspace_lease", "child_lifecycle", "check_receipt",
|
|
39
|
+
] as const;
|
|
40
|
+
export type LedgerEventKind = typeof LEDGER_EVENT_KINDS[number];
|
|
41
|
+
export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
|
|
42
|
+
export type LedgerGateOutcome = typeof LEDGER_GATE_OUTCOMES[number];
|
|
37
43
|
|
|
38
44
|
export interface LedgerEventBase {
|
|
39
45
|
/** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
|
|
40
46
|
ledgerVersion?: typeof LEDGER_VERSION;
|
|
41
|
-
event?:
|
|
47
|
+
event?: LedgerEventKind;
|
|
42
48
|
ts: string;
|
|
43
49
|
childId?: string;
|
|
44
50
|
correlation?: CorrelationMetadata;
|
|
@@ -104,7 +110,7 @@ export interface GrantRecord extends LedgerEventBase {
|
|
|
104
110
|
/** Present only when the source was a live prompt, and only when one scope covers the whole set. */
|
|
105
111
|
approvalScope?: ApprovalScope;
|
|
106
112
|
/** A human was asked and declined. Distinct from `denied`, which is an escalation attempt. */
|
|
107
|
-
humanDenied?:
|
|
113
|
+
humanDenied?: true;
|
|
108
114
|
/**
|
|
109
115
|
* WHY a gate went unsatisfied, when the answer was not a yes.
|
|
110
116
|
*
|
|
@@ -120,9 +126,10 @@ export interface GrantRecord extends LedgerEventBase {
|
|
|
120
126
|
* say *"nobody was there to ask"* and be believed, so it is recorded rather than inferred.
|
|
121
127
|
*
|
|
122
128
|
* **Privacy is unchanged**: this is a fixed five-member enum, not text — nothing model-authored, nothing
|
|
123
|
-
* a task could carry.
|
|
129
|
+
* a task could carry. The prompt has five outcomes, but `granted` is deliberately omitted from the
|
|
130
|
+
* four-member ledger enum because the approval source/scope fields already record a yes.
|
|
124
131
|
*/
|
|
125
|
-
gateOutcome?:
|
|
132
|
+
gateOutcome?: LedgerGateOutcome;
|
|
126
133
|
/**
|
|
127
134
|
* WHICH operator-authored instructions this child was given (ADR-0018).
|
|
128
135
|
*
|
|
@@ -208,6 +215,12 @@ export function buildRecord(args: {
|
|
|
208
215
|
refusal?: StructuredRefusal;
|
|
209
216
|
now: Date;
|
|
210
217
|
}): GrantRecord {
|
|
218
|
+
if (args.taskDigest !== undefined && !/^[a-f0-9]{64}$/i.test(args.taskDigest)) {
|
|
219
|
+
throw new TypeError("taskDigest must be a SHA-256 hex digest");
|
|
220
|
+
}
|
|
221
|
+
if (args.definitionDigest && !/^[a-f0-9]{64}$/i.test(args.definitionDigest.sha256)) {
|
|
222
|
+
throw new TypeError("definitionDigest.sha256 must be a SHA-256 hex digest");
|
|
223
|
+
}
|
|
211
224
|
// R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
|
|
212
225
|
// than accepting it, so a call site cannot supply one that disagrees with the map beside it.
|
|
213
226
|
const sources = args.approvalSources ?? {};
|
|
@@ -217,7 +230,7 @@ export function buildRecord(args: {
|
|
|
217
230
|
return {
|
|
218
231
|
// A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
|
|
219
232
|
// Production delegation always supplies it; old TypeScript callers remain able to append legacy lines.
|
|
220
|
-
...(args.taskDigest ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
|
|
233
|
+
...(args.taskDigest !== undefined ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
|
|
221
234
|
ts: args.now.toISOString(),
|
|
222
235
|
parentId: args.parentId,
|
|
223
236
|
childId: args.childId,
|
|
@@ -225,7 +238,7 @@ export function buildRecord(args: {
|
|
|
225
238
|
agentType: args.agentType,
|
|
226
239
|
executor: args.executor,
|
|
227
240
|
...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
|
|
228
|
-
...(args.taskDigest ? { taskDigest: args.taskDigest } : {}),
|
|
241
|
+
...(args.taskDigest !== undefined ? { taskDigest: args.taskDigest } : {}),
|
|
229
242
|
...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
|
|
230
243
|
...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
|
|
231
244
|
requested: args.requested,
|
|
@@ -302,11 +315,22 @@ export function isEscalationAttempt(record: GrantRecord): boolean {
|
|
|
302
315
|
}
|
|
303
316
|
|
|
304
317
|
export {
|
|
318
|
+
CHILD_LIFECYCLE_STATES,
|
|
319
|
+
CHILD_PROCESS_SIGNALS,
|
|
320
|
+
WORKSPACE_ACCESSES,
|
|
321
|
+
WORKSPACE_LEASE_OUTCOMES,
|
|
322
|
+
WORKSPACE_RECOVERY_VALUES,
|
|
323
|
+
buildCheckReceiptLedgerEvent,
|
|
305
324
|
buildChildLifecycleEvent,
|
|
306
325
|
buildWorkspaceLeaseEvent,
|
|
326
|
+
type CapabilityDecisionEvent,
|
|
307
327
|
type ChildLifecycleEvent,
|
|
328
|
+
type ChildLifecycleState,
|
|
329
|
+
type ChildProcessSignal,
|
|
308
330
|
type CheckReceiptLedgerEvent,
|
|
309
331
|
type RuntimeLedgerEvent,
|
|
332
|
+
type WorkspaceAccess,
|
|
310
333
|
type WorkspaceLeaseEvent,
|
|
311
334
|
type WorkspaceLeaseOutcome,
|
|
335
|
+
type WorkspaceRecovery,
|
|
312
336
|
} from "./ledger-events.ts";
|
package/src/propagation.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import type { Capability } from "./resolve.ts";
|
|
29
29
|
import { WILDCARD } from "./pi-tools.ts";
|
|
30
|
+
import { WORKSPACE_WILDCARD } from "./resolve.ts";
|
|
30
31
|
import { inheritApprovals, type InheritableApproval } from "./approval.ts";
|
|
31
32
|
import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
|
|
32
33
|
|
|
@@ -247,9 +248,28 @@ export interface ChildEnvInput {
|
|
|
247
248
|
* empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow,
|
|
248
249
|
* because a session's first provider request always precedes its first tool call.
|
|
249
250
|
*/
|
|
251
|
+
/**
|
|
252
|
+
* The part of a grant a child may inherit: everything but the wildcards that are HELD, never handed down.
|
|
253
|
+
*
|
|
254
|
+
* `tool:*` for R-26's reason — a root that handed it down let every descendant reacquire the full catalog.
|
|
255
|
+
* `workspace:*` for the same reason one namespace over (R-131): a descendant holding it could route anywhere
|
|
256
|
+
* the registry lists, which is the attenuation failure ADR-0035 exists to close. `agent:*` is deliberately
|
|
257
|
+
* NOT stripped — ADR-0023 decided that a session authorised for any definition passes that on, because
|
|
258
|
+
* definitions are ceilings rather than roots.
|
|
259
|
+
*
|
|
260
|
+
* **Exported, and there is exactly one spelling of this rule on purpose.** It shipped as a filter inline in
|
|
261
|
+
* `childEnv` and nowhere else, so a grant travelling the OTHER path — `delegate.ts` building a child's
|
|
262
|
+
* `PI_GRANTS_GRANT` from `result.effective` — carried `workspace:*` straight down. The test written beside
|
|
263
|
+
* that fix exercised `childEnv`, which is not the path a delegated child's grant travels, so it could not
|
|
264
|
+
* catch it. R-28's shape: two routes for one rule, with the guard on the quieter one. Both call this now.
|
|
265
|
+
*/
|
|
266
|
+
export function inheritableGrant(grant: readonly Capability[]): Capability[] {
|
|
267
|
+
return grant.filter((c) => c !== WILDCARD && c !== WORKSPACE_WILDCARD);
|
|
268
|
+
}
|
|
269
|
+
|
|
250
270
|
export function childEnv(input: ChildEnvInput): Record<string, string> {
|
|
251
271
|
if (input.governed === false) return {};
|
|
252
|
-
const inheritable = input.ownGrant
|
|
272
|
+
const inheritable = inheritableGrant(input.ownGrant);
|
|
253
273
|
const env: Record<string, string> = {
|
|
254
274
|
[ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
|
|
255
275
|
[ENV_DEPTH]: String(input.depth + 1),
|
package/src/refusals.ts
CHANGED
package/src/resolve.ts
CHANGED
|
@@ -76,6 +76,16 @@ import { isWellFormedCapability } from "./capabilities.ts";
|
|
|
76
76
|
* `tool:*` — authority to grant every tool — which made the safe configuration the laborious one.
|
|
77
77
|
*/
|
|
78
78
|
export const AGENT_WILDCARD: Capability = "agent:*";
|
|
79
|
+
/**
|
|
80
|
+
* `workspace:*` covers any `workspace:<id>` — ADR-0035, and the second namespace wildcard.
|
|
81
|
+
*
|
|
82
|
+
* Added as a deliberate edit, which is what the comment in `resolve()` below asks for: there is no
|
|
83
|
+
* generalised `<ns>:*` rule, so a namespace does not acquire a wildcard by existing. Unlike `agent:*` this
|
|
84
|
+
* one is **not inheritable** (`childEnv` strips it) — R-26's rule, because a root that handed
|
|
85
|
+
* `workspace:*` down would make routing attenuation meaningless below the root, which is the exact defect
|
|
86
|
+
* R-131 records.
|
|
87
|
+
*/
|
|
88
|
+
export const WORKSPACE_WILDCARD: Capability = "workspace:*";
|
|
79
89
|
|
|
80
90
|
export interface ResolveInput {
|
|
81
91
|
/** What the delegating agent asked to give the child. */
|
|
@@ -125,7 +135,10 @@ export function resolve(input: ResolveInput): ResolveResult {
|
|
|
125
135
|
const parent =
|
|
126
136
|
input.subsumption === false ? held : new Set(expandSubsumed(input.parentGrant));
|
|
127
137
|
/**
|
|
128
|
-
* `agent:*` covers any `agent:<name>` — ADR-0023
|
|
138
|
+
* `agent:*` covers any `agent:<name>` — ADR-0023. It was the only wildcard rule in this function when
|
|
139
|
+
* that was written; ADR-0035 added a third, so `covered()` now has `tool:*`, `agent:*` and `workspace:*`.
|
|
140
|
+
* There is still no GENERALISED `<ns>:*` rule: each is a deliberate edit, which is the property worth
|
|
141
|
+
* keeping. Corrected because the sentence sat eighty lines above "all THREE wildcards are excluded".
|
|
129
142
|
*
|
|
130
143
|
* `resolve` is otherwise exact-match plus subsumption, deliberately: `tool:*` works not because anything
|
|
131
144
|
* here understands it, but because `deriveOwnGrant` *enumerates* a session's observed tool names beside
|
|
@@ -150,13 +163,17 @@ export function resolve(input: ResolveInput): ResolveResult {
|
|
|
150
163
|
* spellings of one rule, and the enforcing one was wrong.
|
|
151
164
|
*/
|
|
152
165
|
const anyCapability = held.has(WILDCARD);
|
|
166
|
+
const anyWorkspace = held.has(WORKSPACE_WILDCARD);
|
|
153
167
|
// A malformed id is never covered — not by an exact hold, and above all not by a wildcard's PREFIX rule,
|
|
154
168
|
// which is how `agent:x,tool:bash` got admitted and then split into two capabilities in the child.
|
|
155
169
|
// Landing in `denied` is the right outcome rather than a throw: it fails closed AND records an
|
|
156
170
|
// escalation attempt, so the ledger shows the attempt instead of a clean line.
|
|
157
171
|
const covered = (c: Capability): boolean =>
|
|
158
172
|
isWellFormedCapability(c)
|
|
159
|
-
&& (parent.has(c) || anyCapability
|
|
173
|
+
&& (parent.has(c) || anyCapability
|
|
174
|
+
|| (anyDefinition && c.startsWith("agent:"))
|
|
175
|
+
|| (anyWorkspace && c.startsWith("workspace:")));
|
|
176
|
+
|
|
160
177
|
const ceiling = input.ceiling === undefined ? null : new Set(input.ceiling);
|
|
161
178
|
const gated = new Set(input.gated ?? []);
|
|
162
179
|
const approved = new Set(input.approved ?? []);
|
|
@@ -199,7 +216,16 @@ export function resolve(input: ResolveInput): ResolveResult {
|
|
|
199
216
|
// F9: capabilities covered by a WILDCARD are not "subsumed" — this field means "the grant is broader
|
|
200
217
|
// than its list suggests", which is the `bash`-covers-`grep` warning. A wildcard holder already knows
|
|
201
218
|
// its grant is broad; listing every id under it would bury the signal the field exists to carry.
|
|
202
|
-
|
|
219
|
+
//
|
|
220
|
+
// All THREE wildcards are excluded. `anyWorkspace` was missing, so a caller holding `workspace:*` saw
|
|
221
|
+
// its own `workspace:prod` reported as subsumed while the `agent:*` holder correctly saw nothing — the
|
|
222
|
+
// field contradicting its own rule, and a false "broader than it looks" flag in the ledger and in
|
|
223
|
+
// `/grants`. Every wildcard added to `covered()` above needs a line here; that is now three for three.
|
|
224
|
+
subsumedBy: effective.filter((c) =>
|
|
225
|
+
!held.has(c)
|
|
226
|
+
&& !anyCapability
|
|
227
|
+
&& !(anyDefinition && c.startsWith("agent:"))
|
|
228
|
+
&& !(anyWorkspace && c.startsWith("workspace:"))),
|
|
203
229
|
};
|
|
204
230
|
}
|
|
205
231
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-0035's three routing guards, and why each one is shaped the way it is.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `delegate.ts` when it crossed the 400-line ceiling `test/file-size.test.ts` enforces — the
|
|
5
|
+
* same move `grants.ts` made at 398 and `delegate.ts` itself made at 413. It is a real seam rather than a
|
|
6
|
+
* line-count dodge: all three answer one question, *may this caller route this child to this workspace, and
|
|
7
|
+
* is the id even usable as a capability?*, and none of them looks at anything else in a delegation. The
|
|
8
|
+
* alternative was trimming the rationale below to fit, which is how a codebase loses the reasons for its
|
|
9
|
+
* guards.
|
|
10
|
+
*
|
|
11
|
+
* Each returns a refusal DESCRIPTOR rather than a `Delegation`, so the planner keeps sole ownership of
|
|
12
|
+
* assembling records. `denied` is present exactly when the attempt should count as an escalation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mayRouteToWorkspace, isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
|
|
16
|
+
import { WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
17
|
+
import { WILDCARD } from "./pi-tools.ts";
|
|
18
|
+
import type { RefusalCode } from "./refusals.ts";
|
|
19
|
+
|
|
20
|
+
export interface RoutingRefusal {
|
|
21
|
+
code: RefusalCode;
|
|
22
|
+
reason: string;
|
|
23
|
+
/**
|
|
24
|
+
* The capability to record in `denied`, when this refusal IS an escalation attempt.
|
|
25
|
+
*
|
|
26
|
+
* Absent for a malformed id, which is a bad request rather than a bid for authority — and seeding `denied`
|
|
27
|
+
* with an id that re-splits into several capabilities would make the one signal `isEscalationAttempt` reads
|
|
28
|
+
* unparseable. Present for an unauthorised route, which is exactly a bid for authority.
|
|
29
|
+
*/
|
|
30
|
+
denied?: Capability[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* May this caller route a child to `boundWorkspaceId`, and is that id usable as a capability id at all?
|
|
35
|
+
*
|
|
36
|
+
* Checked before anything is said about the target, for the reason `maySpawnDefinition` is: it is a
|
|
37
|
+
* governance question about the SESSION. Before ADR-0035 nothing checked it — the registry inherited into
|
|
38
|
+
* every governed child and a child routed to `staging` could route its grandchild to `prod` (R-131, measured
|
|
39
|
+
* in `docs/probes/g36-workspace-attenuation`).
|
|
40
|
+
*
|
|
41
|
+
* **Well-formedness first, because `workspace_id` is a model-facing tool parameter** and the next step turns
|
|
42
|
+
* it into a capability id. `workspace_id: "prod,tool:bash"` produced a `WORKSPACE_NOT_AUTHORIZED` whose
|
|
43
|
+
* `denied` array held `workspace:prod,tool:bash`: no authority minted, since the refusal is terminal, but
|
|
44
|
+
* `denied` is the channel `isEscalationAttempt` and `/grants ledger` count and an id that re-splits makes it
|
|
45
|
+
* unreadable. 0.18.1 is what happens when a comma goes unremarked.
|
|
46
|
+
*
|
|
47
|
+
* **`!== undefined`, not truthiness.** `workspace_id: ""` is falsy, so it skipped BOTH checks and failed
|
|
48
|
+
* closed much later at `resolveWorkspace` with `denied: []` — an unauthorised routing attempt invisible to
|
|
49
|
+
* every audit query. An empty id is a malformed id, not an absent one.
|
|
50
|
+
*/
|
|
51
|
+
export function checkRoutingAuthority(
|
|
52
|
+
boundWorkspaceId: string | undefined,
|
|
53
|
+
ownGrant: readonly Capability[],
|
|
54
|
+
): RoutingRefusal | null {
|
|
55
|
+
if (boundWorkspaceId === undefined) return null;
|
|
56
|
+
const authorising = workspaceCapability(boundWorkspaceId);
|
|
57
|
+
if (!isSafeWorkspaceId(boundWorkspaceId)) {
|
|
58
|
+
return {
|
|
59
|
+
code: "GRANT_ID_MALFORMED",
|
|
60
|
+
reason:
|
|
61
|
+
`workspace id ${JSON.stringify(boundWorkspaceId)} is not usable as a capability id — it would ` +
|
|
62
|
+
`become ${JSON.stringify(authorising)}, and an id must match [A-Za-z0-9][A-Za-z0-9._/-]*. Slashes ` +
|
|
63
|
+
`and dots are fine; spaces, quotes, commas, wildcards, shell metacharacters and non-ASCII are not.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (mayRouteToWorkspace(ownGrant, boundWorkspaceId)) return null;
|
|
67
|
+
// Filtered by the grammar too: an id that cannot pass `isSafeWorkspaceId` is refused on every
|
|
68
|
+
// attempt, so listing it here points the model at a destination it can never reach.
|
|
69
|
+
const held = ownGrant
|
|
70
|
+
.filter((c) => c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)))
|
|
71
|
+
.sort();
|
|
72
|
+
return {
|
|
73
|
+
code: "WORKSPACE_NOT_AUTHORIZED",
|
|
74
|
+
// Recorded as a denial rather than a bare refusal, exactly as DEFINITION_NOT_AUTHORIZED is: asking to
|
|
75
|
+
// route somewhere this session was not granted IS an attempt to exceed the grant.
|
|
76
|
+
denied: [authorising],
|
|
77
|
+
reason:
|
|
78
|
+
`cannot route a child to workspace "${boundWorkspaceId}" — this session does not hold ${authorising}. ` +
|
|
79
|
+
(held.length > 0
|
|
80
|
+
? `It may route to: ${held.join(", ")}.`
|
|
81
|
+
: `It may route to no workspace at all; add ${authorising} to its grant to allow this one.`),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* May `workspace:*` be handed to a child? No — and only its HOLDER gets told so in those words.
|
|
87
|
+
*
|
|
88
|
+
* `workspace:*` is held and never inherited, so a child that "was granted" it would receive an env without
|
|
89
|
+
* it, and the ledger would record an authority the child does not have — the mirror image of R-131 and just
|
|
90
|
+
* as unreadable. `childEnv` strips it from a session's OWN grant because a root legitimately holds it; asking
|
|
91
|
+
* to hand it to a child is a different act and the honest answer is no. `NARROWING_VIOLATED` is the existing
|
|
92
|
+
* code for "this grant would not actually narrow", which is precisely what routing authority over every
|
|
93
|
+
* registered root does.
|
|
94
|
+
*
|
|
95
|
+
* **`tool:*` does NOT reach the same outcome, and an earlier comment claimed it did.** It said
|
|
96
|
+
* `assertNarrowing` produces the equivalent refusal; it does not. `UNIVERSAL_CAPABILITIES` is
|
|
97
|
+
* `["ext:pi-fabric/fabric_exec", "tool:fabric_exec"]`, so `result.universal` is empty for `tool:*` and
|
|
98
|
+
* nothing throws: requesting it for a child is allowed, recorded as granted, and silently stripped from the
|
|
99
|
+
* child env by `inheritableGrant` (R-135). The asymmetry is deliberate — an ungoverned session's own grant IS
|
|
100
|
+
* `tool:*`, so refusing to spawn from one would break governance-is-opt-in, while `workspace:*` only ever
|
|
101
|
+
* appears in `requested` because somebody asked — but it is an asymmetry, not one rule twice.
|
|
102
|
+
*
|
|
103
|
+
* **Only for a holder.** A caller without it is attempting an escalation, and this refusal would tell it the
|
|
104
|
+
* wildcard "is held, never inherited" — false about a session holding nothing of the sort — while leaving
|
|
105
|
+
* `denied` empty, so nothing counted a model probing `tools: ["workspace:*"]`. Returning `null` lets
|
|
106
|
+
* `resolve()` deny it as uncovered, which is what `agent:*` and `tool:*` already do.
|
|
107
|
+
*/
|
|
108
|
+
export function checkWorkspaceWildcardRequest(
|
|
109
|
+
requested: readonly Capability[],
|
|
110
|
+
ownGrant: readonly Capability[],
|
|
111
|
+
): RoutingRefusal | null {
|
|
112
|
+
if (!requested.includes(WORKSPACE_WILDCARD)) return null;
|
|
113
|
+
if (!ownGrant.includes(WORKSPACE_WILDCARD) && !ownGrant.includes(WILDCARD)) return null;
|
|
114
|
+
return {
|
|
115
|
+
code: "NARROWING_VIOLATED",
|
|
116
|
+
reason:
|
|
117
|
+
`cannot grant ${WORKSPACE_WILDCARD} to a child — it is held, never inherited, because a descendant ` +
|
|
118
|
+
`holding it could route anywhere the registry lists and routing would stop attenuating below here. ` +
|
|
119
|
+
`Name the workspaces this child may route to instead (workspace:<id>, one per id).`,
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/skill-packages.ts
CHANGED
|
@@ -30,7 +30,8 @@ import { homedir } from "node:os";
|
|
|
30
30
|
import { join, resolve, sep } from "node:path";
|
|
31
31
|
import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
|
|
32
32
|
import { WILDCARD } from "./pi-tools.ts";
|
|
33
|
-
import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
|
|
33
|
+
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
34
|
+
import { isSafeCapability } from "./capabilities.ts";
|
|
34
35
|
|
|
35
36
|
export interface DiscoveredSkill {
|
|
36
37
|
definition: SkillDefinition;
|
|
@@ -107,21 +108,27 @@ export function isSafeName(name: string): boolean {
|
|
|
107
108
|
* grants), which is right for the enforcement path — the catalog refuses what it does not know — and is
|
|
108
109
|
* exactly why the check has to be here, at the boundary that *generates* rather than the one that enforces.
|
|
109
110
|
*
|
|
110
|
-
* The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`,
|
|
111
|
-
* `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused
|
|
112
|
-
* loudly, because "you tried to grant yourself everything" is a different fact from "that is
|
|
111
|
+
* The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`,
|
|
112
|
+
* `workspace:<id>`, and `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused
|
|
113
|
+
* separately and loudly, because "you tried to grant yourself everything" is a different fact from "that is
|
|
114
|
+
* not a name".
|
|
115
|
+
*
|
|
116
|
+
* `workspace:` was absent until 2026-08-21, so the boundary that GENERATES grants could not emit the one
|
|
117
|
+
* capability ADR-0035's breaking change made mandatory: a package needing to route was reported as declaring
|
|
118
|
+
* something that "is not a name". A namespace has to be added here as well as to the enforcing path, and
|
|
119
|
+
* that is the whole lesson of the review this came out of.
|
|
113
120
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
|
|
118
|
-
new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id)
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
+
// Imported as well as re-exported: a bare `export … from` does not bind the name in this module's scope, and
|
|
122
|
+
// `refusalFor` below calls it. Re-exported because this has been the import site since 0.13.0.
|
|
123
|
+
export { isSafeCapability };
|
|
121
124
|
|
|
122
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* The ids that confer authority over a whole namespace. A package declaring one is claiming it, not
|
|
127
|
+
* describing a need — so it is reported as a *wildcard claim* rather than as a malformed name, which is a
|
|
128
|
+
* different sentence to show an operator. `workspace:*` belongs here for the same reason `agent:*` does.
|
|
129
|
+
*/
|
|
123
130
|
function wildcardsIn(capabilities: Capability[]): Capability[] {
|
|
124
|
-
return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD);
|
|
131
|
+
return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD || c === WORKSPACE_WILDCARD);
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
/**
|
package/src/workspace-lease.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { homedir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { GovernanceRefusal, refusal } from "./refusals.ts";
|
|
16
16
|
import type { ValidatedWorkspace, WorkspaceAccess } from "./workspace.ts";
|
|
17
|
+
import { assertCloseBounds, HELPER_SOURCE, LEASE_READY, unrefStream } from "./lease-helper.ts";
|
|
17
18
|
|
|
18
19
|
export const ENV_WORKSPACE_LEASE_DIR = "PI_GRANTS_WORKSPACE_LEASE_DIR";
|
|
19
20
|
|
|
@@ -22,28 +23,17 @@ export function defaultWorkspaceLeaseDir(env: NodeJS.ProcessEnv = process.env):
|
|
|
22
23
|
return env[ENV_WORKSPACE_LEASE_DIR] ?? join(agentDir, "pi-daddy", "workspace-leases");
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
const LEASE_READY = "PI_DADDY_LEASE_READY";
|
|
26
|
-
// The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
|
|
27
|
-
// the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
|
|
28
|
-
// descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
|
|
29
|
-
// guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
|
|
30
|
-
// WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
|
|
31
|
-
// and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
|
|
32
|
-
// with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
|
|
33
|
-
const HELPER_SOURCE = `
|
|
34
|
-
import { execFile } from "node:child_process";
|
|
35
|
-
import { writeFileSync } from "node:fs";
|
|
36
|
-
let clean=false, target=null, buffered="";
|
|
37
|
-
process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
|
|
38
|
-
process.stdin.setEncoding("utf8");
|
|
39
|
-
process.stdin.on("data",chunk=>{buffered+=chunk;for(;;){const i=buffered.indexOf("\\n");if(i<0)break;const line=buffered.slice(0,i);buffered=buffered.slice(i+1);try{const value=JSON.parse(line);if(value.release)clean=true;else if(value.process_pid)target={process_pid:value.process_pid};else if(value.herdr_tab)target={herdr_tab:value.herdr_tab};}catch{}}});
|
|
40
|
-
process.stdin.on("end",()=>{if(clean||!target)return process.exit(0);if(target.process_pid){try{process.kill(target.process_pid,"SIGTERM")}catch{return process.exit(0)}setTimeout(()=>{try{process.kill(target.process_pid,"SIGKILL")}catch{}},500);return setTimeout(()=>process.exit(0),750);}let left=Number(process.env.PI_DADDY_LEASE_CLOSE_ATTEMPTS||10);const giveUp=()=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp();setTimeout(close,1000)});close();});
|
|
41
|
-
process.stdin.resume();`;
|
|
42
26
|
|
|
43
27
|
/**
|
|
44
|
-
* The ledger outcome for a release.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
28
|
+
* The ledger outcome for a release. Exported because call sites had their own copies and the check runner's
|
|
29
|
+
* asserted `released` unconditionally — reproducing in the evidence path the exact defect this union was
|
|
30
|
+
* added to expose (R-100/R-103).
|
|
31
|
+
*
|
|
32
|
+
* **It is NOT the only definition, and saying so here was wrong.** `extensions/workspace-runtime.ts` keeps a
|
|
33
|
+
* private copy with no `not-held` arm, and it is the one the whole delegation path uses — so releasing a
|
|
34
|
+
* *read* lease on a delegated child is still recorded `released`, a handover the kernel never performed.
|
|
35
|
+
* Measured; open as R-141; the sentence claiming a single definition survived two review passes because
|
|
36
|
+
* `test/workspace.test.ts` pins the `not-held` arm on THIS function, which no delegation path calls.
|
|
47
37
|
*/
|
|
48
38
|
export function leaseReleaseLedgerOutcome(
|
|
49
39
|
outcome: LeaseReleaseOutcome | "retained",
|
|
@@ -78,9 +68,17 @@ export async function acquireWorkspaceLease(input: {
|
|
|
78
68
|
signal?: AbortSignal;
|
|
79
69
|
flockCommand?: string;
|
|
80
70
|
acquisitionTimeoutMs?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Bound on how long ONE `herdr tab close` attempt may take before it counts as failed (R-146). A hung
|
|
73
|
+
* herdr is not a failed herdr: without this the attempt never returns and the retry bound below is
|
|
74
|
+
* unreachable.
|
|
75
|
+
*/
|
|
76
|
+
herdrCloseTimeoutMs?: number;
|
|
81
77
|
/** Bound on the helper's `herdr tab close` retries before it releases the lock anyway (R-102). */
|
|
82
78
|
herdrCloseAttempts?: number;
|
|
83
79
|
}): Promise<WorkspaceLease> {
|
|
80
|
+
// Before the read-lease return, so both paths are validated (R-152).
|
|
81
|
+
assertCloseBounds(input);
|
|
84
82
|
if (input.access === "read") {
|
|
85
83
|
return {
|
|
86
84
|
workspace: input.workspace,
|
|
@@ -89,7 +87,9 @@ export async function acquireWorkspaceLease(input: {
|
|
|
89
87
|
recovered: false,
|
|
90
88
|
attachProcess: () => {},
|
|
91
89
|
attachHerdrTab: () => {},
|
|
92
|
-
|
|
90
|
+
// A read lease took no kernel lock, so there is nothing to keep: `not-held` is the same answer
|
|
91
|
+
// `release()` gives, and it is the truth rather than a retention that never happened (R-152).
|
|
92
|
+
markRetained: async () => "not-held" as const,
|
|
93
93
|
readCloseFailure: async () => null,
|
|
94
94
|
lost: new Promise(() => {}),
|
|
95
95
|
// No kernel lock was ever taken, so there is no handover to claim (R-105, release side).
|
|
@@ -108,7 +108,12 @@ export async function acquireWorkspaceLease(input: {
|
|
|
108
108
|
process.execPath, "--input-type=module", "-e", HELPER_SOURCE,
|
|
109
109
|
], {
|
|
110
110
|
stdio: ["pipe", "pipe", "pipe"],
|
|
111
|
-
env: {
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
PI_DADDY_LEASE_MARKER: paths.marker,
|
|
114
|
+
PI_DADDY_LEASE_CLOSE_ATTEMPTS: String(input.herdrCloseAttempts ?? 10),
|
|
115
|
+
PI_DADDY_LEASE_CLOSE_TIMEOUT_MS: String(input.herdrCloseTimeoutMs ?? 15_000),
|
|
116
|
+
},
|
|
112
117
|
// Own process group, for two reasons. It lets teardown kill `flock` AND the helper holding the lock
|
|
113
118
|
// file descriptor as one unit even before the helper has reported its pid (R-99), and it stops a
|
|
114
119
|
// stray group signal — a terminal Ctrl-C, a closed window — from releasing a live writer's lock as
|
|
@@ -240,12 +245,67 @@ export async function acquireWorkspaceLease(input: {
|
|
|
240
245
|
async markRetained(reason = "retained") {
|
|
241
246
|
// Deliberately does NOT touch the kernel lock or the helper: the pane may still be live. It only
|
|
242
247
|
// stops the record from looking like a crash.
|
|
248
|
+
//
|
|
249
|
+
// **Both guards below exist because the CALLER ledgers whatever this returns (R-152).**
|
|
250
|
+
//
|
|
251
|
+
// Already settled: `release()` checked `settled` and this did not, so a completed handover could be
|
|
252
|
+
// rewritten into `retained:…` and the memoized answer flipped with it — the mirror of the defect R-146
|
|
253
|
+
// fixed, reachable by the `try { … } finally { await lease.release() }` shape this API invites.
|
|
254
|
+
if (settled) return settled;
|
|
255
|
+
// Already dead: the helper is gone and the kernel lock with it, so the fact is `lost`. Recording a
|
|
256
|
+
// retention here tells an operator a pane may still be live and tells the next owner nothing crashed —
|
|
257
|
+
// precisely the two facts R-103's outcome union was added to keep apart.
|
|
258
|
+
if (holder.exitCode !== null || holder.signalCode !== null) return (settled = "lost");
|
|
243
259
|
const current = await readMetadata(paths.metadata);
|
|
260
|
+
let recorded = false;
|
|
244
261
|
if (current !== "malformed" && current?.token === token) {
|
|
245
|
-
await atomicMetadata(paths.metadata, {
|
|
262
|
+
recorded = await atomicMetadata(paths.metadata, {
|
|
246
263
|
...metadata, state: "released", released_at: new Date().toISOString(), release_reason: `retained:${reason}`,
|
|
247
|
-
}).
|
|
264
|
+
}).then(() => true, () => false);
|
|
248
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* **It must, however, let THIS process exit (R-146).** Leaving the helper alone is the decision;
|
|
268
|
+
* leaving the parent's three pipes and the child handle referenced was an accident, and it meant a
|
|
269
|
+
* retained lease wedged its own process forever — measured `exit=124` against `exit=0` for the same
|
|
270
|
+
* sequence ending in `release()`.
|
|
271
|
+
*
|
|
272
|
+
* **Which hosts it actually wedged, corrected after review.** `process.exit()` runs `exit` handlers and
|
|
273
|
+
* ignores pending handles, so this only bites a host that lets the loop drain: pi's **print** mode sets
|
|
274
|
+
* `process.exitCode` and returns (`dist/main.js`), and a library consumer — an ADR-0034 external
|
|
275
|
+
* controller calling this package directly — does the same. pi's interactive and rpc modes call
|
|
276
|
+
* `process.exit()` explicitly (`dist/modes/interactive/interactive-mode.js:3148`), so there the process
|
|
277
|
+
* still leaves and `pane-reaper`'s `process.once("exit")` sweep still runs. An earlier version of this
|
|
278
|
+
* comment claimed the sweep was lost generally and cited `src/cli.ts`, which has one subcommand
|
|
279
|
+
* (`init`) and can neither hold a lease nor open a pane.
|
|
280
|
+
*
|
|
281
|
+
* Unref rather than close, because the lock must outlive this call: when the parent does exit, the
|
|
282
|
+
* helper sees EOF and runs the path it was written for — bounded `herdr tab close` attempts, a marker
|
|
283
|
+
* file, then release anyway (R-102: an unreleasable lock strands a worktree with no in-product
|
|
284
|
+
* recovery, which is strictly worse than a recorded failure to close). Narrowed to this path on
|
|
285
|
+
* purpose: at spawn it would remove the accidental guarantee that a process cannot exit while a lease
|
|
286
|
+
* is still ACTIVE, which is a different decision and not this fix.
|
|
287
|
+
*/
|
|
288
|
+
/**
|
|
289
|
+
* **Terminal (R-146).** `releasing` stops a late `holder.close` being reported as a lost lease, and
|
|
290
|
+
* `settled` stops a later `release()` running the clean handshake — which it did: measured, it returned
|
|
291
|
+
* `released`, overwrote `retained:herdr-close-failed` with `completed`, and sent `{release:true}` so the
|
|
292
|
+
* helper exited `clean` and never attempted the close. The pane retention exists to protect was
|
|
293
|
+
* abandoned silently, and the ledger asserted a clean handover for a lease kept *because* a pane would
|
|
294
|
+
* not close. No in-tree caller does this — but `WorkspaceLease` is a public export and
|
|
295
|
+
* `try { … } finally { await lease.release() }` is the obvious shape for the external controllers this
|
|
296
|
+
* whole fix is for.
|
|
297
|
+
*/
|
|
298
|
+
releasing = true;
|
|
299
|
+
// **Settle only if the record was actually written.** An unwritable lease directory used to leave the
|
|
300
|
+
// record `state: "active"` while the ledger said `retained`, so the next owner reported a phantom
|
|
301
|
+
// `recovered: true` — and making retention terminal removed the later `release()` that would still have
|
|
302
|
+
// written the handover. Unsettled, that repair is available again; the residue is stated in R-152
|
|
303
|
+
// rather than implied, because nothing here can write a record to a directory that refuses writes.
|
|
304
|
+
if (recorded) settled = "retained";
|
|
305
|
+
holder.unref();
|
|
306
|
+
unrefStream(holder.stdout);
|
|
307
|
+
unrefStream(holder.stderr);
|
|
308
|
+
return "retained";
|
|
249
309
|
},
|
|
250
310
|
async readCloseFailure() {
|
|
251
311
|
try {
|