pi-daddy 0.18.0 → 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 +154 -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 +105 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +161 -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 +32 -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 +22 -2
- 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 +2 -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 +33 -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 +173 -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 +42 -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 +23 -2
- package/src/refusals.ts +2 -0
- package/src/resolve.ts +35 -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/catalog.ts
CHANGED
|
@@ -23,9 +23,11 @@ import { homedir } from "node:os";
|
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { loadDefinitions, type SkillDefinition } from "./definitions.ts";
|
|
25
25
|
import { PI_BUILTIN_TOOLS, WILDCARD } from "./pi-tools.ts";
|
|
26
|
-
import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
|
|
26
|
+
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
27
|
+
import { loadWorkspaceRegistry, type WorkspaceRegistryFile } from "./workspace.ts";
|
|
28
|
+
import { isSafeWorkspaceId } from "./capabilities.ts";
|
|
27
29
|
|
|
28
|
-
export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType";
|
|
30
|
+
export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType" | "workspace";
|
|
29
31
|
|
|
30
32
|
export interface CatalogEntry {
|
|
31
33
|
capability: Capability;
|
|
@@ -112,6 +114,22 @@ export function definitionEntries(definitions: Map<string, SkillDefinition>): Ca
|
|
|
112
114
|
}));
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Registered workspaces, as `workspace:<id>` capabilities (ADR-0035).
|
|
119
|
+
*
|
|
120
|
+
* For DISPLAY and SCAFFOLDING only — `/grants` listing what this session may route to, and `init` offering
|
|
121
|
+
* the ids without choosing among them (ADR-0028). It is deliberately **not** what `unknownCapabilities`
|
|
122
|
+
* checks against; see the comment there.
|
|
123
|
+
*
|
|
124
|
+
* The operator registry is the authority on which ids exist, exactly as it is at `resolveWorkspace`. This
|
|
125
|
+
* enumerates it; it does not decide anything.
|
|
126
|
+
*/
|
|
127
|
+
export function workspaceEntries(registry: WorkspaceRegistryFile, source?: string): CatalogEntry[] {
|
|
128
|
+
return Object.keys(registry.workspaces)
|
|
129
|
+
.sort()
|
|
130
|
+
.map((id) => ({ capability: `workspace:${id}` as Capability, kind: "workspace" as const, ...(source ? { source } : {}) }));
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
/** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
|
|
116
134
|
export function makeCatalog(entries: CatalogEntry[]): Catalog {
|
|
117
135
|
const deduped = new Map<Capability, CatalogEntry>();
|
|
@@ -131,8 +149,23 @@ export function makeCatalog(entries: CatalogEntry[]): Catalog {
|
|
|
131
149
|
export async function buildCatalog(input: {
|
|
132
150
|
cwd: string;
|
|
133
151
|
observedTools: string[] | null;
|
|
152
|
+
/** Operator workspace registry (`PI_GRANTS_WORKSPACE_REGISTRY`). Absent or unreadable yields no entries. */
|
|
153
|
+
registryPath?: string;
|
|
134
154
|
}): Promise<Catalog> {
|
|
135
|
-
const [skills, definitions] = await Promise.all([
|
|
155
|
+
const [skills, definitions, workspaces] = await Promise.all([
|
|
156
|
+
loadSkills(input.cwd),
|
|
157
|
+
loadDefinitions(input.cwd),
|
|
158
|
+
// Fails SOFT, and only because nothing here is an authority. A malformed registry must not stop a
|
|
159
|
+
// session from starting — `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that
|
|
160
|
+
// refusal is the operator's signal at the point of USE, where routing actually depends on it. Swallowing
|
|
161
|
+
// it there would be unsafe; swallowing it here costs a display list.
|
|
162
|
+
input.registryPath
|
|
163
|
+
? loadWorkspaceRegistry(input.registryPath).then(
|
|
164
|
+
(r) => workspaceEntries(r, input.registryPath),
|
|
165
|
+
() => [] as CatalogEntry[],
|
|
166
|
+
)
|
|
167
|
+
: Promise.resolve([] as CatalogEntry[]),
|
|
168
|
+
]);
|
|
136
169
|
return makeCatalog([
|
|
137
170
|
// pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
|
|
138
171
|
// consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
|
|
@@ -148,6 +181,7 @@ export async function buildCatalog(input: {
|
|
|
148
181
|
...(input.observedTools ? classifyToolNames(input.observedTools) : []),
|
|
149
182
|
...skills,
|
|
150
183
|
...definitionEntries(definitions),
|
|
184
|
+
...workspaces,
|
|
151
185
|
]);
|
|
152
186
|
}
|
|
153
187
|
|
|
@@ -165,7 +199,31 @@ export function unknownCapabilities(requested: Capability[], catalog: Catalog):
|
|
|
165
199
|
// refused it BEFORE `resolve` could apply ADR-0023's rule. That made the ADR's "a parent holding
|
|
166
200
|
// `agent:*` may hand down `agent:*`" false, and made a definition declaring `allowed-tools: agent:*`
|
|
167
201
|
// unspawnable from any grant. The wildcard is live only at the root without this.
|
|
168
|
-
|
|
202
|
+
//
|
|
203
|
+
// `workspace:` is exempt as a NAMESPACE, not merely at its wildcard, and that asymmetry is deliberate.
|
|
204
|
+
// ADR-0035 minted the namespace and taught `normaliseCapability`, `resolve` and `childEnv` about it, but
|
|
205
|
+
// not this line — so with a catalog present (and `delegationContext` always supplies one) every requested
|
|
206
|
+
// `workspace:<id>` was refused UNKNOWN_TOOL as *"a typo, or an uninstalled package"*. A child could
|
|
207
|
+
// therefore never be granted a workspace capability at all, which made routing stop dead below the root
|
|
208
|
+
// instead of attenuating, and made the ADR's own "two authorities, not one" unreachable in production.
|
|
209
|
+
//
|
|
210
|
+
// Exempt rather than catalogued-and-checked because the operator registry is the authority and it is
|
|
211
|
+
// consulted where it matters: `resolveWorkspace` refuses an unregistered id with WORKSPACE_NOT_REGISTERED,
|
|
212
|
+
// naming the registry. A second, weaker check here can only turn that precise refusal into a misleading
|
|
213
|
+
// one — and it would do so for reasons that have nothing to do with the id, like a registry this session
|
|
214
|
+
// cannot read. `buildCatalog` still ENUMERATES workspaces, for `/grants` and for `init`'s scaffold; that
|
|
215
|
+
// is display, and display is not authority. Same trade-off the built-ins comment above states and accepts.
|
|
216
|
+
// The workspace exemption requires a WELL-FORMED id, not merely the prefix. A bare `workspace:` names
|
|
217
|
+
// nothing, and exempting it let it reach a child's grant and the ledger as authority over no workspace at
|
|
218
|
+
// all — an exemption for ids the registry is authoritative about should not also cover ids no registry
|
|
219
|
+
// could contain.
|
|
220
|
+
// `WORKSPACE_WILDCARD` is listed with the other two because it is GRAMMAR, and `isSafeCapability` refuses
|
|
221
|
+
// wildcards by design — so folding it into the namespace test below un-exempts it. Caught by the tests for
|
|
222
|
+
// the previous two fixes, which is the checklist paying for itself.
|
|
223
|
+
const exempt = (c: Capability) =>
|
|
224
|
+
c === WILDCARD || c === AGENT_WILDCARD || c === WORKSPACE_WILDCARD
|
|
225
|
+
|| (c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)));
|
|
226
|
+
return requested.filter((c) => !exempt(c) && !catalog.has(c)).sort();
|
|
169
227
|
}
|
|
170
228
|
|
|
171
229
|
/**
|
package/src/check-runner.ts
CHANGED
|
@@ -5,9 +5,8 @@ import { basename, isAbsolute, join } from "node:path";
|
|
|
5
5
|
import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
|
|
6
6
|
import {
|
|
7
7
|
appendLedgerEvent,
|
|
8
|
+
buildCheckReceiptLedgerEvent,
|
|
8
9
|
buildWorkspaceLeaseEvent,
|
|
9
|
-
type CheckReceiptLedgerEvent,
|
|
10
|
-
LEDGER_VERSION,
|
|
11
10
|
} from "./ledger.ts";
|
|
12
11
|
import { computeGitCandidateIdentity } from "./git-identity.ts";
|
|
13
12
|
import { runChild } from "./run-child.ts";
|
|
@@ -272,12 +271,13 @@ export async function runNamedCheck(input: {
|
|
|
272
271
|
};
|
|
273
272
|
const receipt: CheckReceipt = { receipt_id: receiptId(body), ...body };
|
|
274
273
|
if (input.ledgerPath) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
274
|
+
await appendLedgerEvent(
|
|
275
|
+
{ path: input.ledgerPath, strict: true },
|
|
276
|
+
buildCheckReceiptLedgerEvent({
|
|
277
|
+
childId: ownerId, receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId,
|
|
278
|
+
checkId: input.checkId, treeSha: receipt.tree_sha, correlation, now: ended,
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
281
|
}
|
|
282
282
|
return { output: result.text, exitCode: result.code, signal: result.signal ?? null, receipt };
|
|
283
283
|
} catch (error) {
|
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { relative, resolve as resolvePath } from "node:path";
|
|
|
19
19
|
import { pathToFileURL } from "node:url";
|
|
20
20
|
import { UnsafeGrantError } from "./grant-env.ts";
|
|
21
21
|
import { applyInit, countDeclaring, planInit, type InitPlan } from "./init.ts";
|
|
22
|
+
import { registeredWorkspaceIds } from "./workspace.ts";
|
|
22
23
|
import { discoverSkillPackages, skillPackageRoots, type RefusedSkill, type SkillPackage } from "./skill-packages.ts";
|
|
23
24
|
|
|
24
25
|
const USAGE = `pi-daddy — capability governance for pi sub-agents
|
|
@@ -109,7 +110,7 @@ async function init(cwd: string, force: boolean): Promise<number> {
|
|
|
109
110
|
|
|
110
111
|
let plan: InitPlan;
|
|
111
112
|
try {
|
|
112
|
-
plan = planInit(packages, cwd);
|
|
113
|
+
plan = planInit(packages, cwd, await registeredWorkspaceIds());
|
|
113
114
|
} catch (error) {
|
|
114
115
|
// R-78's backstop reaching the surface. Nothing is written: a grant that could mean something to a
|
|
115
116
|
// shell is not a grant, and half-scaffolding a project would be worse than scaffolding none of it.
|
|
@@ -210,6 +211,28 @@ function report(plan: InitPlan): void {
|
|
|
210
211
|
);
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// ROUTING, which had no line here at all — and `report()` is the output of the command the docs tell an
|
|
215
|
+
// operator to run. Keeping `workspace:` ids out of `withheldCapabilities` (so the `/grants init` dialog
|
|
216
|
+
// could not grant them off a package declaration) removed the ONLY thing this path said about a definition
|
|
217
|
+
// that cannot be spawned: `needs-withheld` is not one of the cases handled above, so a routing package
|
|
218
|
+
// produced a copied definition, an unusable grant, and total silence.
|
|
219
|
+
//
|
|
220
|
+
// The fix that caused it argued that "a breaking change whose migration is only discoverable by opening a
|
|
221
|
+
// file is not much of a migration" — and then applied that to the in-session notify and not to the CLI.
|
|
222
|
+
if (plan.routableWorkspaces.length > 0) {
|
|
223
|
+
const blocked = plan.skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name);
|
|
224
|
+
console.log(
|
|
225
|
+
`\nROUTABLE WORKSPACES: ${plan.routableWorkspaces.join(", ")}.\n` +
|
|
226
|
+
`Routing a child to one needs its id in PI_GRANTS_GRANT (ADR-0035); without it the delegation is\n` +
|
|
227
|
+
`refused WORKSPACE_NOT_AUTHORIZED. They are listed COMMENTED in .pi/grants.env and never granted for\n` +
|
|
228
|
+
`you — which worktree a child starts in is not something a package can declare.` +
|
|
229
|
+
(blocked.length > 0
|
|
230
|
+
? `\nUntil you grant one, these cannot be spawned: ${blocked.join(", ")} — add the capability, then\n` +
|
|
231
|
+
`their \`agent:\` ids.`
|
|
232
|
+
: ""),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
213
236
|
console.log(
|
|
214
237
|
`\nLive grant (${plan.grant.length} capabilities): ${plan.grant.join(", ")}\n\n` +
|
|
215
238
|
` $EDITOR .pi/grants.env # review it, then commit it\n` +
|
package/src/definitions.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { createHash } from "node:crypto";
|
|
|
23
23
|
import { readFile, readdir } from "node:fs/promises";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { skillDirs } from "./catalog.ts";
|
|
26
|
+
import { CAPABILITY_NAMESPACE_PREFIXES } from "./capabilities.ts";
|
|
26
27
|
import type { Capability } from "./resolve.ts";
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -182,7 +183,12 @@ export function ceilingForDefinition(definition: SkillDefinition): DefinitionCei
|
|
|
182
183
|
patterns.push(entry);
|
|
183
184
|
continue;
|
|
184
185
|
}
|
|
185
|
-
|
|
186
|
+
// Every namespaced id passes through untouched; only a BARE tool name gets the `tool:` prefix and the
|
|
187
|
+
// lowercasing. `workspace:` was missing here after ADR-0035 taught `normaliseCapability` about it, so a
|
|
188
|
+
// definition declaring `allowed-tools: workspace:prod` produced `tool:workspace:prod` — a capability
|
|
189
|
+
// that names nothing, refused as unknown, and dropped by `init` as "probably a typo or an attack". Two
|
|
190
|
+
// spellings of one grammar with this one wrong is R-28's shape; the list is now shared.
|
|
191
|
+
if (CAPABILITY_NAMESPACE_PREFIXES.some((prefix) => entry.startsWith(prefix))) {
|
|
186
192
|
capabilities.add(entry);
|
|
187
193
|
continue;
|
|
188
194
|
}
|
package/src/delegate.ts
CHANGED
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
import { planSpawn } from "./spawn.ts";
|
|
7
7
|
import { ceilingForDefinition, digestDefinition, type DefinitionDigest, type SkillDefinition } from "./definitions.ts";
|
|
8
8
|
import { assertNarrowing, type Capability, type ResolveResult } from "./resolve.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { checkRoutingAuthority, checkWorkspaceWildcardRequest } from "./routing-authority.ts";
|
|
10
|
+
import {
|
|
11
|
+
DELEGATE_CAPABILITY,
|
|
12
|
+
agentCapability,
|
|
13
|
+
maySpawnDefinition,
|
|
14
|
+
normaliseCapability,
|
|
15
|
+
} from "./capabilities.ts";
|
|
10
16
|
|
|
11
17
|
// Re-exported so the split stays internal: `delegate.ts` has been the import site for these since 0.6.0 and
|
|
12
18
|
// four modules plus the test suite name it. Moving the definitions without moving the door would be churn
|
|
13
19
|
// charged to every caller for a line count they did not cause.
|
|
14
20
|
export { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
|
|
15
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
ENV_APPROVED, ENV_DEPTH, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID,
|
|
23
|
+
inheritableGrant,
|
|
24
|
+
} from "./propagation.ts";
|
|
16
25
|
import { inheritApprovals, type InheritableApproval } from "./approval.ts";
|
|
17
26
|
import { suggestForUnknown, unknownCapabilities, type Catalog } from "./catalog.ts";
|
|
18
27
|
import { GovernanceRefusal, refusal, type RefusalCode, type StructuredRefusal } from "./refusals.ts";
|
|
@@ -24,6 +33,7 @@ import {
|
|
|
24
33
|
} from "./correlation.ts";
|
|
25
34
|
import { resolveDelegationApproval } from "./delegation-approval.ts";
|
|
26
35
|
import type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
|
|
36
|
+
import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
|
|
27
37
|
export type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
|
|
28
38
|
|
|
29
39
|
export function planDelegation(request: DelegationRequest, ctx: DelegationContext): Delegation {
|
|
@@ -71,6 +81,17 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
71
81
|
}
|
|
72
82
|
if (!request.task?.trim()) return denied({ ...empty, reason: "a delegation needs a task" }, "TASK_MISSING");
|
|
73
83
|
|
|
84
|
+
// ADR-0035's routing guards live in `routing-authority.ts` with their rationale; both are checked here,
|
|
85
|
+
// before anything is said about the target, because they are governance questions about the SESSION.
|
|
86
|
+
const routing = checkRoutingAuthority(request.boundWorkspaceId, ctx.ownGrant);
|
|
87
|
+
if (routing) {
|
|
88
|
+
return denied({
|
|
89
|
+
...empty,
|
|
90
|
+
...(routing.denied ? { requested: routing.denied, result: { ...empty.result, denied: routing.denied } } : {}),
|
|
91
|
+
reason: routing.reason,
|
|
92
|
+
}, routing.code);
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
// ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling.
|
|
75
96
|
let requested: Capability[];
|
|
76
97
|
let systemPrompt: string | undefined;
|
|
@@ -180,6 +201,13 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
180
201
|
}
|
|
181
202
|
}
|
|
182
203
|
|
|
204
|
+
// See `checkWorkspaceWildcardRequest`: refused for a HOLDER (the ledger must not record authority the
|
|
205
|
+
// child will not receive), and left to `resolve()` for anyone else, so the probe lands in `denied`.
|
|
206
|
+
const wildcardRequest = checkWorkspaceWildcardRequest(requested, ctx.ownGrant);
|
|
207
|
+
if (wildcardRequest) {
|
|
208
|
+
return denied({ ...empty, requested, reason: wildcardRequest.reason }, wildcardRequest.code);
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
const { result, approvalBinding, bindingMismatch } = resolveDelegationApproval({
|
|
184
212
|
task: request.task,
|
|
185
213
|
agent: request.agent,
|
|
@@ -265,8 +293,14 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
265
293
|
args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
|
|
266
294
|
}
|
|
267
295
|
|
|
296
|
+
// `inheritableGrant`, not `result.effective` directly: this is the path a DELEGATED child's grant
|
|
297
|
+
// actually travels, and the "held but never inherited" rule for `tool:*` and `workspace:*` was enforced
|
|
298
|
+
// only in `childEnv`. A parent holding `workspace:*` could request it for its child and this line handed
|
|
299
|
+
// it over, so the rule ADR-0035 advertises held by accident — masked downstream rather than enforced
|
|
300
|
+
// here. One spelling of the rule, called from both paths.
|
|
301
|
+
const inheritable = inheritableGrant(result.effective);
|
|
268
302
|
const env: Record<string, string> = {
|
|
269
|
-
[ENV_GRANT]:
|
|
303
|
+
[ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
|
|
270
304
|
[ENV_DEPTH]: String(childDepth),
|
|
271
305
|
[ENV_MAX_DEPTH]: String(ctx.maxDepth),
|
|
272
306
|
};
|
|
@@ -280,7 +314,11 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
280
314
|
// `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states
|
|
281
315
|
// the child's approval set outright rather than leaving it to whatever the caller merges over; see
|
|
282
316
|
// `mergeChildEnv`, which is what actually stops the parent's value leaking through.
|
|
283
|
-
|
|
317
|
+
// Clamped to what the child actually INHERITS, not to what it was granted. The two differ only for a
|
|
318
|
+
// non-inheritable wildcard, and passing down an approval for a capability the child does not hold would
|
|
319
|
+
// leave banked authority with nothing to spend it on — `childEnv` clamps to `inheritable` for the same
|
|
320
|
+
// reason on the other path.
|
|
321
|
+
env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(",");
|
|
284
322
|
if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
|
|
285
323
|
|
|
286
324
|
return {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DELEGATE_SUBJECT, type InheritableApproval } from "./approval.ts";
|
|
2
|
-
import { agentCapability } from "./capabilities.ts";
|
|
2
|
+
import { agentCapability, workspaceCapability } from "./capabilities.ts";
|
|
3
3
|
import {
|
|
4
4
|
approvalBindingsEqual,
|
|
5
5
|
buildApprovalBinding,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type CorrelationMetadata,
|
|
8
8
|
} from "./correlation.ts";
|
|
9
9
|
import type { DefinitionDigest, SkillDefinition } from "./definitions.ts";
|
|
10
|
-
import { AGENT_WILDCARD, resolve, type Capability, type ResolveResult } from "./resolve.ts";
|
|
10
|
+
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, resolve, type Capability, type ResolveResult } from "./resolve.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Resolve the approval half of one delegation after its requested capability set is known.
|
|
@@ -44,15 +44,32 @@ export function resolveDelegationApproval(input: {
|
|
|
44
44
|
approved: [],
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Authorities the PARENT is spending on this one delegation, gated as such.
|
|
49
|
+
*
|
|
50
|
+
* ADR-0024 established the shape for `agent:<name>`: the id is gated as the parent's authority to run
|
|
51
|
+
* that definition *now*, and never joins requested/effective, because that would hand the child authority
|
|
52
|
+
* to recursively spawn itself. ADR-0035 added a second member of the category — `workspace:<id>`, the
|
|
53
|
+
* authority to route this child somewhere — and shipped without it, so `PI_GRANTS_GATED=workspace:prod`
|
|
54
|
+
* was accepted, recorded, and silently inert: no human was ever asked. The ADR claimed the opposite in
|
|
55
|
+
* three places.
|
|
56
|
+
*
|
|
57
|
+
* A LIST rather than two variables on purpose. This is the third namespace whose authorising id is gated
|
|
58
|
+
* per-delegation rather than granted downward, and the first two were written as one special case each;
|
|
59
|
+
* a fourth should extend an array, not add a third `if` and a third field to thread through.
|
|
60
|
+
*/
|
|
61
|
+
const authorisingCapabilities: Capability[] = [];
|
|
62
|
+
const gateAuthority = (authorising: Capability, wildcard: Capability) => {
|
|
63
|
+
if (input.gated.includes(authorising) || input.gated.includes(wildcard)) {
|
|
64
|
+
authorisingCapabilities.push(authorising);
|
|
54
65
|
unapproved.gatedBlocked = [...unapproved.gatedBlocked, authorising];
|
|
55
66
|
}
|
|
67
|
+
};
|
|
68
|
+
if (input.spawned) gateAuthority(agentCapability(input.spawned.name), AGENT_WILDCARD);
|
|
69
|
+
// Trusted: `boundWorkspaceId` is set only from a routing spec resolved against the operator registry,
|
|
70
|
+
// never from a model-supplied `correlation` claim (R-110).
|
|
71
|
+
if (input.boundWorkspaceId) {
|
|
72
|
+
gateAuthority(workspaceCapability(input.boundWorkspaceId), WORKSPACE_WILDCARD);
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
const potential = resolve({
|
|
@@ -92,8 +109,16 @@ export function resolveDelegationApproval(input: {
|
|
|
92
109
|
gated: input.gated,
|
|
93
110
|
approved: approvedCapabilities,
|
|
94
111
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
// Filtered against what `resolve()` already listed, not just against approvals. In the ORDINARY chained
|
|
113
|
+
// configuration — route the child to `prod` AND grant it `workspace:prod` so it can route onward — the
|
|
114
|
+
// authorising id and the requested id are spelled identically, so appending unconditionally produced
|
|
115
|
+
// `gatedBlocked: ["workspace:prod","workspace:prod"]`, which reached the refusal text a model reads
|
|
116
|
+
// (*"workspace:prod, workspace:prod requires explicit approval"*) and the append-only ledger, whose schema
|
|
117
|
+
// has no `uniqueItems`. The old `agent:`-only code had the same shape and never hit it, because a
|
|
118
|
+
// self-recursive `agent:X` ceiling is not a thing anybody writes.
|
|
119
|
+
const stillGated = authorisingCapabilities.filter(
|
|
120
|
+
(c) => !approvedCapabilities.includes(c) && !result.gatedBlocked.includes(c),
|
|
121
|
+
);
|
|
122
|
+
if (stillGated.length > 0) result.gatedBlocked = [...result.gatedBlocked, ...stillGated];
|
|
98
123
|
return { result, ...(approvalBinding ? { approvalBinding } : {}), bindingMismatch };
|
|
99
124
|
}
|
package/src/executor.ts
CHANGED
|
@@ -18,7 +18,8 @@ import type { HerdrProbe } from "./herdr-cli.ts";
|
|
|
18
18
|
|
|
19
19
|
export const ENV_HERDR = "PI_GRANTS_HERDR";
|
|
20
20
|
|
|
21
|
-
export
|
|
21
|
+
export const EXECUTOR_KINDS = ["process", "herdr"] as const;
|
|
22
|
+
export type ExecutorKind = typeof EXECUTOR_KINDS[number];
|
|
22
23
|
|
|
23
24
|
export interface ExecutorChoice {
|
|
24
25
|
kind: ExecutorKind;
|
package/src/grant-env.ts
CHANGED
|
@@ -42,6 +42,12 @@ export const WITHHELD_BY_DEFAULT: readonly Capability[] = [
|
|
|
42
42
|
|
|
43
43
|
/** Would `init` put this capability in the live grant? */
|
|
44
44
|
export function isLiveByDefault(capability: Capability): boolean {
|
|
45
|
+
// No `workspace:<id>` is ever live by default (ADR-0035), and this is the same rule the list above states
|
|
46
|
+
// rather than a new one: authority "does not become live because a package asked for it". A registry id is
|
|
47
|
+
// a *choice of where a child runs*, which is the operator's to make and cannot be inferred from a
|
|
48
|
+
// declaration — ADR-0028's whole position. Enumerated ids are unbounded, so this is a namespace test and
|
|
49
|
+
// not a list membership.
|
|
50
|
+
if (capability.startsWith("workspace:")) return false;
|
|
45
51
|
return !WITHHELD_BY_DEFAULT.includes(capability);
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -87,6 +93,8 @@ export interface GrantEnvInput {
|
|
|
87
93
|
withheld: Map<Capability, string[]>;
|
|
88
94
|
/** Definitions whose `agent:` id is withheld because they need a withheld capability. */
|
|
89
95
|
withheldDefinitions: string[];
|
|
96
|
+
/** `workspace:<id>` ids this project could route to (ADR-0035). Rendered commented, never granted. */
|
|
97
|
+
routableWorkspaces?: Capability[];
|
|
90
98
|
/** `agent:<name>` ids a ceiling names that `init` did not write here. Reported, never granted. */
|
|
91
99
|
crossReferences: { from: string; capability: Capability }[];
|
|
92
100
|
cautions: string[];
|
|
@@ -140,13 +148,37 @@ export function renderGrantEnv(input: GrantEnvInput): string {
|
|
|
140
148
|
for (const [capability, needed] of [...input.withheld].sort()) {
|
|
141
149
|
lines.push(`# ${capability.padEnd(width)} (${needed.join(", ")})`);
|
|
142
150
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
151
|
+
lines.push("#");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// OUTSIDE the block above, and that is the fix. This is the file's only statement that a withheld
|
|
155
|
+
// definition's `agent:` id must be granted too — and it used to render only when `withheld` was non-empty,
|
|
156
|
+
// so a package whose sole withheld capability is a ROUTING id (the common read-only routing case) lost it
|
|
157
|
+
// entirely once `workspace:` ids stopped going into that map. The reason line above still said "see below"
|
|
158
|
+
// and pointed at nothing.
|
|
159
|
+
if (input.withheldDefinitions.length > 0) {
|
|
160
|
+
lines.push(
|
|
161
|
+
`# …and then: ${input.withheldDefinitions.map((n) => `agent:${n}`).join(",")}`,
|
|
162
|
+
"# Their `agent:` ids are withheld too: a definition that cannot receive what it declares would",
|
|
163
|
+
"# be authorised to run and then refused, which is a worse answer than not being authorised.",
|
|
164
|
+
"#",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ADR-0035. Routing became a capability in 0.19.0, which made every existing grant that routes start
|
|
169
|
+
// refusing — so the migration has to be visible from the file the operator already opens. Listed and NOT
|
|
170
|
+
// granted: which worktree a child starts in is the operator's decision and cannot be read off a
|
|
171
|
+
// declaration (ADR-0028), and granting one because a package named it is the "does not become live because
|
|
172
|
+
// a package asked for it" rule that `WITHHELD_BY_DEFAULT` above states.
|
|
173
|
+
if (input.routableWorkspaces && input.routableWorkspaces.length > 0) {
|
|
174
|
+
lines.push(
|
|
175
|
+
"# ROUTABLE WORKSPACES — routing a child to a registered worktree needs the id in PI_GRANTS_GRANT",
|
|
176
|
+
"# (ADR-0035, 0.19.0). Without it a delegation naming one is refused WORKSPACE_NOT_AUTHORIZED. Add the",
|
|
177
|
+
"# ones this project's children may start in; a child can only pass on ids it holds itself, so this is",
|
|
178
|
+
"# also the list of what any DESCENDANT could reach. Not granted for you: `workspace:*` exists but is",
|
|
179
|
+
"# held and never inherited, which makes it the wrong answer for anything but a single-worktree setup.",
|
|
180
|
+
);
|
|
181
|
+
for (const capability of input.routableWorkspaces) lines.push(`# ${capability}`);
|
|
150
182
|
lines.push("#");
|
|
151
183
|
}
|
|
152
184
|
|
package/src/index.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { mkdir, open, rm, writeFile } from "node:fs/promises";
|
|
26
26
|
import { join } from "node:path";
|
|
27
|
-
import { agentCapability } from "./capabilities.ts";
|
|
27
|
+
import { agentCapability, workspaceCapability } from "./capabilities.ts";
|
|
28
28
|
import { ceilingForDefinition } from "./definitions.ts";
|
|
29
29
|
import { ALWAYS_LIVE, assertGrantIsWritable, isLiveByDefault, renderGrantEnv, type GrantEnvSkill } from "./grant-env.ts";
|
|
30
30
|
import { PI_BUILTIN_TOOLS } from "./pi-tools.ts";
|
|
@@ -78,6 +78,8 @@ export interface InitPlan {
|
|
|
78
78
|
grant: Capability[];
|
|
79
79
|
/** Withheld capability → the definitions that declared it (ADR-0029). Emitted commented. */
|
|
80
80
|
withheldCapabilities: Map<Capability, string[]>;
|
|
81
|
+
/** `workspace:<id>` this project could route to (ADR-0035). Always commented — `init` does not choose. */
|
|
82
|
+
routableWorkspaces: Capability[];
|
|
81
83
|
grantEnvPath: string;
|
|
82
84
|
grantEnvContent: string;
|
|
83
85
|
/** Capabilities a declared ceiling names that pi 0.84.1 has no tool for — a caution, not a verdict. */
|
|
@@ -143,7 +145,20 @@ function unknownToolIds(capabilities: Capability[]): Capability[] {
|
|
|
143
145
|
* tools at all and the whole file is inert. Everything else is emitted commented, named, and one uncomment
|
|
144
146
|
* away (ADR-0029).
|
|
145
147
|
*/
|
|
146
|
-
export function planInit(
|
|
148
|
+
export function planInit(
|
|
149
|
+
packages: SkillPackage[],
|
|
150
|
+
cwd: string,
|
|
151
|
+
/**
|
|
152
|
+
* Ids from the operator's workspace registry, when one is configured — read by the CALLER, because
|
|
153
|
+
* `planInit` is pure and stays that way.
|
|
154
|
+
*
|
|
155
|
+
* ADR-0035 made routing a capability and said `init` "scaffolds the registered ids so the common path is a
|
|
156
|
+
* one-line grant edit". It did not: `init` had never heard of the registry, so the ADR's own stated
|
|
157
|
+
* migration path for a breaking change did not exist. These are emitted **commented**, never live —
|
|
158
|
+
* offering the ids while refusing to choose among them is exactly ADR-0028's position.
|
|
159
|
+
*/
|
|
160
|
+
registeredWorkspaceIds: readonly string[] = [],
|
|
161
|
+
): InitPlan {
|
|
147
162
|
const skills: PlannedSkill[] = [];
|
|
148
163
|
const collisions: string[] = [];
|
|
149
164
|
const seen = new Set<string>();
|
|
@@ -190,6 +205,20 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
|
|
|
190
205
|
for (const skill of declared) {
|
|
191
206
|
for (const capability of skill.ceiling) {
|
|
192
207
|
if (isLiveByDefault(capability)) continue;
|
|
208
|
+
// A routing destination is withheld but does NOT belong in this map, and the difference is not
|
|
209
|
+
// cosmetic. This map drives two things: the "WITHHELD BY DEFAULT — these can change your machine"
|
|
210
|
+
// block, and `/grants init`'s dialog. Review found a `workspace:` id reaching both — described to the
|
|
211
|
+
// operator with `tool:bash`'s rationale (routing does not change your machine, and unlike `bash` it
|
|
212
|
+
// *is* gateable), and then granted **live and persisted** on one "Yes", off a third-party package's
|
|
213
|
+
// declaration. That is the rule `grant-env.ts` states — "does not become live because a package asked
|
|
214
|
+
// for it" — honoured by the rendered file and broken by the dialog beside it: two surfaces of one
|
|
215
|
+
// command disagreeing, which is R-28's shape inside the fix for R-28.
|
|
216
|
+
//
|
|
217
|
+
// Which worktree a child starts in is not derivable from a declaration (ADR-0028), so `init` lists
|
|
218
|
+
// routing and never grants it. `routableWorkspaces` below is where these go; the definition that
|
|
219
|
+
// declared one still loses its live `agent:` id via the `needs-withheld` pass, so nothing becomes
|
|
220
|
+
// spawnable behind the operator's back either.
|
|
221
|
+
if (capability.startsWith("workspace:")) continue;
|
|
193
222
|
withheldCapabilities.set(capability, [...(withheldCapabilities.get(capability) ?? []), skill.name]);
|
|
194
223
|
}
|
|
195
224
|
}
|
|
@@ -246,11 +275,19 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
|
|
|
246
275
|
...(s.withheld ? { unspawnable: describe[s.withheld](s) } : {}),
|
|
247
276
|
}));
|
|
248
277
|
|
|
278
|
+
// Registry ids the operator could route to, plus any a copied definition actually declares — a package
|
|
279
|
+
// naming `workspace:prod` is evidence that id matters here, and it must still be uncommented by hand.
|
|
280
|
+
const declaredWorkspaces = skills.flatMap((s) => s.ceiling.filter((c) => c.startsWith("workspace:")));
|
|
281
|
+
const routableWorkspaces = [
|
|
282
|
+
...new Set([...registeredWorkspaceIds.map(workspaceCapability), ...declaredWorkspaces]),
|
|
283
|
+
].sort();
|
|
284
|
+
|
|
249
285
|
return {
|
|
250
286
|
skills,
|
|
251
287
|
collisions,
|
|
252
288
|
grant,
|
|
253
289
|
withheldCapabilities,
|
|
290
|
+
routableWorkspaces,
|
|
254
291
|
grantEnvPath: join(cwd, ".pi", "grants.env"),
|
|
255
292
|
grantEnvContent: renderGrantEnv({
|
|
256
293
|
skills: grantEnvSkills,
|
|
@@ -259,6 +296,7 @@ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
|
|
|
259
296
|
withheldDefinitions: skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name),
|
|
260
297
|
crossReferences,
|
|
261
298
|
cautions,
|
|
299
|
+
routableWorkspaces,
|
|
262
300
|
}),
|
|
263
301
|
cautions,
|
|
264
302
|
};
|