@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +157 -45
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- package/package.json +11 -5
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent dispatcher (Sprint a5.4 — M1 gap remediation D).
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher is the runtime side of the @pugi/sdk subagent contracts.
|
|
5
|
+
* Given a SubagentTask, it:
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolves the role to a Cyber-Zoo persona via the local registry
|
|
8
|
+
* (apps/pugi-cli/src/core/agents/registry.ts, which itself sources
|
|
9
|
+
* @pugi/personas).
|
|
10
|
+
* 2. Classifies isolation per the M1 matrix (see isolationForRole).
|
|
11
|
+
* 3. Builds the dispatch-time permission overrides (Vera as reviewer
|
|
12
|
+
* or verifier loses every edit/write/bash class — see
|
|
13
|
+
* permissionOverridesForRole).
|
|
14
|
+
* 4. Emits subagent.spawned into the session events log.
|
|
15
|
+
* 5. Runs the dispatch (M1: stub returning shipped immediately so the
|
|
16
|
+
* contract surface is exercisable; M2+ swaps the body for
|
|
17
|
+
* worktree-isolated execution backed by runEngineLoop).
|
|
18
|
+
* 6. Emits subagent.completed | blocked | failed into the session
|
|
19
|
+
* events log.
|
|
20
|
+
* 7. Returns the typed SubagentResult.
|
|
21
|
+
*
|
|
22
|
+
* Why a stub at M1: the contract surface itself, the event emission, the
|
|
23
|
+
* isolation classification, and the permission overrides are real
|
|
24
|
+
* load-bearing pieces — the cabinet UI, audit replay, and triple-review
|
|
25
|
+
* gating all read these events. The model-driven loop that actually
|
|
26
|
+
* spawns a separate Anvil session is alpha-5.7 work (REPL-by-default).
|
|
27
|
+
* The stub returns a shipped result with the correct persona slug + role
|
|
28
|
+
* pair so downstream consumers can wire against the real shape.
|
|
29
|
+
*
|
|
30
|
+
* The dispatcher is the only place that knows the isolation matrix and
|
|
31
|
+
* the permission overrides. Both surfaces are exported so engine adapter
|
|
32
|
+
* code, tests, and the future REPL can introspect a role without
|
|
33
|
+
* actually running a dispatch.
|
|
34
|
+
*/
|
|
35
|
+
import { randomUUID } from 'node:crypto';
|
|
36
|
+
import { subagentTaskSchema } from '@pugi/sdk';
|
|
37
|
+
import { getPersonaForRole } from '../agents/registry.js';
|
|
38
|
+
/* ------------------------------------------------------------------ */
|
|
39
|
+
/* Isolation matrix */
|
|
40
|
+
/* ------------------------------------------------------------------ */
|
|
41
|
+
/**
|
|
42
|
+
* M1 isolation matrix (ADR-0056 Sprint a5.4 acceptance #2).
|
|
43
|
+
*
|
|
44
|
+
* The function is pure (same role in, same isolation out) and exported
|
|
45
|
+
* separately so consumers (tests, REPL UI) can introspect without
|
|
46
|
+
* dispatching.
|
|
47
|
+
*/
|
|
48
|
+
export function isolationForRole(role) {
|
|
49
|
+
switch (role) {
|
|
50
|
+
case 'orchestrator':
|
|
51
|
+
return 'prompt_only';
|
|
52
|
+
case 'architect':
|
|
53
|
+
case 'verifier':
|
|
54
|
+
case 'reviewer':
|
|
55
|
+
case 'researcher':
|
|
56
|
+
return 'shared_fs_readonly';
|
|
57
|
+
case 'coder':
|
|
58
|
+
case 'release':
|
|
59
|
+
case 'devops':
|
|
60
|
+
case 'design_qa':
|
|
61
|
+
return 'shared_fs_serialized';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/* ------------------------------------------------------------------ */
|
|
65
|
+
/* Permission overrides */
|
|
66
|
+
/* ------------------------------------------------------------------ */
|
|
67
|
+
/**
|
|
68
|
+
* Per-role permission overrides applied at dispatch time. The dominant
|
|
69
|
+
* case is Vera's dual-role rule (ADR-0056 Sprint a5.4 acceptance #4):
|
|
70
|
+
* when dispatched as verifier OR reviewer, Vera gets edit: deny (which
|
|
71
|
+
* we generalize to deny edit + write + bash, the three classes that can
|
|
72
|
+
* mutate the workspace) so a review pass cannot accidentally patch what
|
|
73
|
+
* it is reviewing.
|
|
74
|
+
*
|
|
75
|
+
* Read-only research roles (architect, researcher) get the same
|
|
76
|
+
* three-class deny because their shared_fs_readonly isolation tier is
|
|
77
|
+
* the load-bearing contract; repeating the override at the permission
|
|
78
|
+
* layer is defense in depth so a future bug in isolation classification
|
|
79
|
+
* cannot silently grant a write.
|
|
80
|
+
*
|
|
81
|
+
* Write-capable roles (coder, release, devops, design_qa) get no
|
|
82
|
+
* override; they inherit the workspace permission settings as-is.
|
|
83
|
+
*
|
|
84
|
+
* orchestrator also gets no override; Mira runs inside the parent
|
|
85
|
+
* context, so the parent's permission settings already govern her.
|
|
86
|
+
*/
|
|
87
|
+
export function permissionOverridesForRole(role) {
|
|
88
|
+
switch (role) {
|
|
89
|
+
case 'verifier':
|
|
90
|
+
case 'reviewer':
|
|
91
|
+
return DENY_ALL_WRITES_VERA;
|
|
92
|
+
case 'architect':
|
|
93
|
+
case 'researcher':
|
|
94
|
+
return DENY_ALL_WRITES_READONLY;
|
|
95
|
+
case 'orchestrator':
|
|
96
|
+
case 'coder':
|
|
97
|
+
case 'release':
|
|
98
|
+
case 'devops':
|
|
99
|
+
case 'design_qa':
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const DENY_ALL_WRITES_VERA = Object.freeze([
|
|
104
|
+
{
|
|
105
|
+
toolClass: 'edit',
|
|
106
|
+
allowedPaths: Object.freeze([]),
|
|
107
|
+
reason: 'Vera dispatched as verifier/reviewer (ADR-0056 section a5.4 acceptance #4)',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
toolClass: 'write',
|
|
111
|
+
allowedPaths: Object.freeze([]),
|
|
112
|
+
reason: 'Vera dispatched as verifier/reviewer (ADR-0056 section a5.4 acceptance #4)',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
toolClass: 'bash',
|
|
116
|
+
allowedPaths: Object.freeze([]),
|
|
117
|
+
reason: 'Vera dispatched as verifier/reviewer (ADR-0056 section a5.4 acceptance #4)',
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
const DENY_ALL_WRITES_READONLY = Object.freeze([
|
|
121
|
+
{
|
|
122
|
+
toolClass: 'edit',
|
|
123
|
+
allowedPaths: Object.freeze([]),
|
|
124
|
+
reason: 'read-only role (shared_fs_readonly isolation tier)',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
toolClass: 'write',
|
|
128
|
+
allowedPaths: Object.freeze([]),
|
|
129
|
+
reason: 'read-only role (shared_fs_readonly isolation tier)',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
toolClass: 'bash',
|
|
133
|
+
allowedPaths: Object.freeze([]),
|
|
134
|
+
reason: 'read-only role (shared_fs_readonly isolation tier)',
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
/* ------------------------------------------------------------------ */
|
|
138
|
+
/* Default budgets */
|
|
139
|
+
/* ------------------------------------------------------------------ */
|
|
140
|
+
const DEFAULT_BUDGETS = Object.freeze({
|
|
141
|
+
orchestrator: { tokens: 200_000, dollars: 5, wallClockMs: 600_000 },
|
|
142
|
+
architect: { tokens: 80_000, dollars: 2, wallClockMs: 300_000 },
|
|
143
|
+
coder: { tokens: 120_000, dollars: 3, wallClockMs: 600_000 },
|
|
144
|
+
verifier: { tokens: 60_000, dollars: 2, wallClockMs: 300_000 },
|
|
145
|
+
reviewer: { tokens: 80_000, dollars: 2, wallClockMs: 300_000 },
|
|
146
|
+
researcher: { tokens: 60_000, dollars: 1.5, wallClockMs: 300_000 },
|
|
147
|
+
release: { tokens: 40_000, dollars: 1, wallClockMs: 180_000 },
|
|
148
|
+
devops: { tokens: 60_000, dollars: 2, wallClockMs: 300_000 },
|
|
149
|
+
design_qa: { tokens: 60_000, dollars: 1.5, wallClockMs: 300_000 },
|
|
150
|
+
});
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the effective budget for a dispatch by merging task overrides
|
|
153
|
+
* onto the role default. Caller-supplied limits always tighten, never
|
|
154
|
+
* relax — a missing field falls back to the role default.
|
|
155
|
+
*/
|
|
156
|
+
export function budgetForRole(role, override) {
|
|
157
|
+
const base = DEFAULT_BUDGETS[role];
|
|
158
|
+
if (!override)
|
|
159
|
+
return base;
|
|
160
|
+
return {
|
|
161
|
+
tokens: override.tokens ?? base.tokens,
|
|
162
|
+
dollars: override.dollars ?? base.dollars,
|
|
163
|
+
wallClockMs: override.wallClockMs ?? base.wallClockMs,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/* ------------------------------------------------------------------ */
|
|
167
|
+
/* Dispatch */
|
|
168
|
+
/* ------------------------------------------------------------------ */
|
|
169
|
+
/**
|
|
170
|
+
* Spawn a subagent. M1 implementation is a stub that synchronously
|
|
171
|
+
* returns a shipped result so the contract surface is exercised by
|
|
172
|
+
* tests and the cabinet UI. M2+ replaces the body with an Anvil-side
|
|
173
|
+
* dispatch over a per-task worktree (ADR-0057, deferred).
|
|
174
|
+
*
|
|
175
|
+
* The function still emits real subagent.spawned and subagent.completed
|
|
176
|
+
* events; downstream consumers (audit replay, cabinet activity feed,
|
|
177
|
+
* eval harness) cannot tell the stub apart from a real dispatch on the
|
|
178
|
+
* event surface alone, which is the property we want for forward-
|
|
179
|
+
* compatibility testing.
|
|
180
|
+
*
|
|
181
|
+
* The function rejects with ZodError when the task fails schema
|
|
182
|
+
* validation. Throwing rather than returning a failed result is the
|
|
183
|
+
* right call here: a malformed dispatch is a caller bug, not a subagent
|
|
184
|
+
* failure, and surfacing it as a thrown error keeps the audit log
|
|
185
|
+
* clean.
|
|
186
|
+
*/
|
|
187
|
+
export async function dispatch(task, ctx) {
|
|
188
|
+
const validated = subagentTaskSchema.parse(task);
|
|
189
|
+
const persona = getPersonaForRole(validated.role);
|
|
190
|
+
const isolation = isolationForRole(validated.role);
|
|
191
|
+
void budgetForRole(validated.role, validated.budget);
|
|
192
|
+
void permissionOverridesForRole(validated.role);
|
|
193
|
+
const now = ctx.now ?? defaultNow;
|
|
194
|
+
const startedAt = Date.now();
|
|
195
|
+
ctx.appendEvent({
|
|
196
|
+
id: randomUUID(),
|
|
197
|
+
sessionId: ctx.sessionId,
|
|
198
|
+
timestamp: now(),
|
|
199
|
+
type: 'subagent.spawned',
|
|
200
|
+
taskId: validated.id,
|
|
201
|
+
role: validated.role,
|
|
202
|
+
personaSlug: persona.slug,
|
|
203
|
+
parentSessionId: ctx.sessionId,
|
|
204
|
+
isolation,
|
|
205
|
+
});
|
|
206
|
+
const status = 'shipped';
|
|
207
|
+
const summary = stubSummaryFor(validated.role, persona.name);
|
|
208
|
+
const result = {
|
|
209
|
+
taskId: validated.id,
|
|
210
|
+
role: validated.role,
|
|
211
|
+
personaSlug: persona.slug,
|
|
212
|
+
status,
|
|
213
|
+
summary,
|
|
214
|
+
filesChanged: [],
|
|
215
|
+
toolCallCount: 0,
|
|
216
|
+
tokensIn: 0,
|
|
217
|
+
tokensOut: 0,
|
|
218
|
+
durationMs: Date.now() - startedAt,
|
|
219
|
+
};
|
|
220
|
+
ctx.appendEvent({
|
|
221
|
+
id: randomUUID(),
|
|
222
|
+
sessionId: ctx.sessionId,
|
|
223
|
+
timestamp: now(),
|
|
224
|
+
type: 'subagent.completed',
|
|
225
|
+
taskId: result.taskId,
|
|
226
|
+
role: result.role,
|
|
227
|
+
personaSlug: result.personaSlug,
|
|
228
|
+
toolCallCount: result.toolCallCount,
|
|
229
|
+
tokensIn: result.tokensIn,
|
|
230
|
+
tokensOut: result.tokensOut,
|
|
231
|
+
durationMs: result.durationMs,
|
|
232
|
+
});
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
function stubSummaryFor(role, personaName) {
|
|
236
|
+
return `${personaName} (${role}) dispatched: stub returning shipped (M1 contract surface only; real dispatch in alpha-5.7)`;
|
|
237
|
+
}
|
|
238
|
+
function defaultNow() {
|
|
239
|
+
return new Date().toISOString();
|
|
240
|
+
}
|
|
241
|
+
/* ------------------------------------------------------------------ */
|
|
242
|
+
/* Convenience helpers */
|
|
243
|
+
/* ------------------------------------------------------------------ */
|
|
244
|
+
/**
|
|
245
|
+
* Build a dispatch context tied to an in-memory event sink. Useful for
|
|
246
|
+
* unit tests that want to assert on emitted events without standing up
|
|
247
|
+
* a real .pugi/ directory. Production callers use spawnSubagent (in
|
|
248
|
+
* sibling spawn.ts), which closes over a real PugiSession.
|
|
249
|
+
*/
|
|
250
|
+
export function inMemoryDispatcherContext(input) {
|
|
251
|
+
return {
|
|
252
|
+
sessionId: input.sessionId,
|
|
253
|
+
workspaceRoot: input.workspaceRoot,
|
|
254
|
+
appendEvent: (event) => input.sink.push(event),
|
|
255
|
+
now: input.now,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent runtime surface for the Pugi CLI (Sprint a5.4 — M1 gap
|
|
3
|
+
* remediation D).
|
|
4
|
+
*
|
|
5
|
+
* Re-exports the dispatcher + helpers under a single import path so
|
|
6
|
+
* engine adapter code, the REPL, and tests can pull in everything they
|
|
7
|
+
* need with one import statement:
|
|
8
|
+
*
|
|
9
|
+
* import { dispatch, isolationForRole, ... } from '../core/subagents/index.js';
|
|
10
|
+
*
|
|
11
|
+
* The submodule index does not re-export persona types — those live in
|
|
12
|
+
* @pugi/personas and are pulled in by core/agents/registry.ts. Mixing
|
|
13
|
+
* the persona surface and the dispatcher surface in a single barrel
|
|
14
|
+
* would invite the kind of accidental drift the persona-registry
|
|
15
|
+
* extraction was designed to prevent.
|
|
16
|
+
*/
|
|
17
|
+
export { budgetForRole, dispatch, inMemoryDispatcherContext, isolationForRole, permissionOverridesForRole, } from './dispatcher.js';
|
|
18
|
+
/**
|
|
19
|
+
* Spawn a subagent from inside the engine adapter loop. Re-exported via
|
|
20
|
+
* the barrel so engine code does not have to import the dispatcher
|
|
21
|
+
* module directly. The actual task_dispatch tool that the model uses
|
|
22
|
+
* to invoke a subagent lands in alpha-5.7 (REPL); for now the helper
|
|
23
|
+
* exists so adapter code has a single seam to wire against.
|
|
24
|
+
*/
|
|
25
|
+
export { spawnSubagent } from './spawn.js';
|
|
26
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { recordSubagentBlocked, recordSubagentCompleted, recordSubagentFailed, recordSubagentSpawned, recordSubagentToolCall, } from '../session.js';
|
|
2
|
+
import { dispatch } from './dispatcher.js';
|
|
3
|
+
/**
|
|
4
|
+
* Spawn a subagent under an existing PugiSession. Events are routed
|
|
5
|
+
* through the session module's recorder functions; if the session is
|
|
6
|
+
* disabled (no .pugi/ directory), the recorders short-circuit and the
|
|
7
|
+
* dispatch still runs — the contract is "dispatch always works, audit
|
|
8
|
+
* is best-effort".
|
|
9
|
+
*/
|
|
10
|
+
export async function spawnSubagent(task, session) {
|
|
11
|
+
const ctx = {
|
|
12
|
+
sessionId: session.id,
|
|
13
|
+
workspaceRoot: session.root,
|
|
14
|
+
appendEvent: (event) => routeEvent(event, session),
|
|
15
|
+
};
|
|
16
|
+
return dispatch(task, ctx);
|
|
17
|
+
}
|
|
18
|
+
function routeEvent(event, session) {
|
|
19
|
+
if (!isRecord(event))
|
|
20
|
+
return;
|
|
21
|
+
const type = event['type'];
|
|
22
|
+
if (typeof type !== 'string')
|
|
23
|
+
return;
|
|
24
|
+
switch (type) {
|
|
25
|
+
case 'subagent.spawned':
|
|
26
|
+
recordSubagentSpawned(session, {
|
|
27
|
+
taskId: stringField(event, 'taskId'),
|
|
28
|
+
role: stringField(event, 'role'),
|
|
29
|
+
personaSlug: stringField(event, 'personaSlug'),
|
|
30
|
+
parentSessionId: stringField(event, 'parentSessionId'),
|
|
31
|
+
isolation: stringField(event, 'isolation'),
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
case 'subagent.tool_call':
|
|
35
|
+
recordSubagentToolCall(session, {
|
|
36
|
+
taskId: stringField(event, 'taskId'),
|
|
37
|
+
role: stringField(event, 'role'),
|
|
38
|
+
personaSlug: stringField(event, 'personaSlug'),
|
|
39
|
+
toolName: stringField(event, 'toolName'),
|
|
40
|
+
toolCallId: stringField(event, 'toolCallId'),
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
case 'subagent.completed':
|
|
44
|
+
recordSubagentCompleted(session, {
|
|
45
|
+
taskId: stringField(event, 'taskId'),
|
|
46
|
+
role: stringField(event, 'role'),
|
|
47
|
+
personaSlug: stringField(event, 'personaSlug'),
|
|
48
|
+
toolCallCount: numberField(event, 'toolCallCount'),
|
|
49
|
+
tokensIn: numberField(event, 'tokensIn'),
|
|
50
|
+
tokensOut: numberField(event, 'tokensOut'),
|
|
51
|
+
durationMs: numberField(event, 'durationMs'),
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
case 'subagent.blocked':
|
|
55
|
+
recordSubagentBlocked(session, {
|
|
56
|
+
taskId: stringField(event, 'taskId'),
|
|
57
|
+
role: stringField(event, 'role'),
|
|
58
|
+
personaSlug: stringField(event, 'personaSlug'),
|
|
59
|
+
reason: stringField(event, 'reason'),
|
|
60
|
+
detail: stringField(event, 'detail'),
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
case 'subagent.failed':
|
|
64
|
+
recordSubagentFailed(session, {
|
|
65
|
+
taskId: stringField(event, 'taskId'),
|
|
66
|
+
role: stringField(event, 'role'),
|
|
67
|
+
personaSlug: stringField(event, 'personaSlug'),
|
|
68
|
+
error: stringField(event, 'error'),
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
default:
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function isRecord(value) {
|
|
76
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
77
|
+
}
|
|
78
|
+
function stringField(event, key) {
|
|
79
|
+
const v = event[key];
|
|
80
|
+
return typeof v === 'string' ? v : '';
|
|
81
|
+
}
|
|
82
|
+
function numberField(event, key) {
|
|
83
|
+
const v = event[key];
|
|
84
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=spawn.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
/**
|
|
6
|
+
* Project trust gate for workspace-scoped configs.
|
|
7
|
+
*
|
|
8
|
+
* Hooks under `<workspaceRoot>/.pugi/hooks.json` are dangerous: they execute
|
|
9
|
+
* arbitrary shell commands on every tool event. A malicious repo could ship
|
|
10
|
+
* a `.pugi/hooks.json` that exfiltrates secrets the first time a user runs
|
|
11
|
+
* `pugi code` inside it.
|
|
12
|
+
*
|
|
13
|
+
* The gate prevents this: project hooks load ONLY when the workspace root
|
|
14
|
+
* is explicitly trusted by the user. The trust ledger lives in
|
|
15
|
+
* `~/.pugi/trusted-workspaces.json` and is keyed by absolute path.
|
|
16
|
+
*
|
|
17
|
+
* The user-level config at `~/.pugi/hooks.json` is always loaded (the user
|
|
18
|
+
* authored it themselves on their own machine). Only project-scoped hooks
|
|
19
|
+
* pass through the gate.
|
|
20
|
+
*
|
|
21
|
+
* α5.6 will wire `pugi config trust .` as the user-facing entry point.
|
|
22
|
+
* For α5.3 the trust ledger primitives ship so the hook registry can gate
|
|
23
|
+
* on them; manual trust during dev is documented as a one-line script
|
|
24
|
+
* (see `apps/pugi-cli/scripts/trust-workspace.ts`).
|
|
25
|
+
*/
|
|
26
|
+
const trustEntrySchema = z.object({
|
|
27
|
+
workspaceRoot: z.string().min(1),
|
|
28
|
+
trustedAt: z.string().datetime(),
|
|
29
|
+
trustedBy: z.string().min(1),
|
|
30
|
+
});
|
|
31
|
+
const trustLedgerSchema = z.object({
|
|
32
|
+
schema: z.number().int().positive().default(1),
|
|
33
|
+
entries: z.array(trustEntrySchema).default([]),
|
|
34
|
+
});
|
|
35
|
+
const TRUST_LEDGER_FILENAME = 'trusted-workspaces.json';
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the trust ledger path. The PUGI_HOME env var lets tests redirect
|
|
38
|
+
* the user-home location without polluting the real `~/.pugi` directory.
|
|
39
|
+
*/
|
|
40
|
+
function trustLedgerPath() {
|
|
41
|
+
const home = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
|
|
42
|
+
return resolve(home, TRUST_LEDGER_FILENAME);
|
|
43
|
+
}
|
|
44
|
+
function readLedger() {
|
|
45
|
+
const path = trustLedgerPath();
|
|
46
|
+
if (!existsSync(path)) {
|
|
47
|
+
return { schema: 1, entries: [] };
|
|
48
|
+
}
|
|
49
|
+
const raw = readFileSync(path, 'utf8');
|
|
50
|
+
if (raw.trim() === '') {
|
|
51
|
+
return { schema: 1, entries: [] };
|
|
52
|
+
}
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
return trustLedgerSchema.parse(parsed);
|
|
55
|
+
}
|
|
56
|
+
function writeLedger(ledger) {
|
|
57
|
+
const path = trustLedgerPath();
|
|
58
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
59
|
+
// Mode 0o600 — only the owning user should read this file. The contents
|
|
60
|
+
// do not include secrets but they do reveal which directories on the
|
|
61
|
+
// user's disk are AI-agent workspaces.
|
|
62
|
+
writeFileSync(path, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
function normaliseRoot(root) {
|
|
65
|
+
// Resolve to drop trailing slashes and `.` segments, then realpath so
|
|
66
|
+
// the canonical key is the underlying physical path. A user who trusts
|
|
67
|
+
// `~/projects/foo` (a symlink) trusts whatever foo points to AT TRUST
|
|
68
|
+
// TIME — `readFileSync` follows symlinks anyway, so the previous
|
|
69
|
+
// policy of storing the unresolved path created a TOCTOU window: an
|
|
70
|
+
// attacker who controlled the symlink target after grant could swap
|
|
71
|
+
// it and have their `.pugi/hooks.json` executed on the next pugi run.
|
|
72
|
+
// Storing the realpath closes that window. If the path does not
|
|
73
|
+
// exist yet (e.g. trusting a workspace that will be created later),
|
|
74
|
+
// fall back to the resolved-but-unrealpath'd form.
|
|
75
|
+
const resolved = resolve(root);
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync(resolved);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function isTrustedWorkspace(root) {
|
|
84
|
+
const key = normaliseRoot(root);
|
|
85
|
+
const ledger = readLedger();
|
|
86
|
+
return ledger.entries.some((entry) => entry.workspaceRoot === key);
|
|
87
|
+
}
|
|
88
|
+
export async function trustWorkspace(root, by) {
|
|
89
|
+
const key = normaliseRoot(root);
|
|
90
|
+
const ledger = readLedger();
|
|
91
|
+
const filtered = ledger.entries.filter((entry) => entry.workspaceRoot !== key);
|
|
92
|
+
filtered.push({
|
|
93
|
+
workspaceRoot: key,
|
|
94
|
+
trustedAt: new Date().toISOString(),
|
|
95
|
+
trustedBy: by,
|
|
96
|
+
});
|
|
97
|
+
writeLedger({ schema: ledger.schema, entries: filtered });
|
|
98
|
+
}
|
|
99
|
+
export async function revokeTrust(root) {
|
|
100
|
+
const key = normaliseRoot(root);
|
|
101
|
+
const ledger = readLedger();
|
|
102
|
+
const filtered = ledger.entries.filter((entry) => entry.workspaceRoot !== key);
|
|
103
|
+
writeLedger({ schema: ledger.schema, entries: filtered });
|
|
104
|
+
}
|
|
105
|
+
export async function listTrusted() {
|
|
106
|
+
const ledger = readLedger();
|
|
107
|
+
return [...ledger.entries];
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=trust.js.map
|