@pugi/cli 0.1.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -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
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from './runtime/cli.js';
3
+ runCli(process.argv.slice(2)).catch((error) => {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ console.error(`pugi: ${message}`);
6
+ process.exitCode = 1;
7
+ });
8
+ //# sourceMappingURL=index.js.map