@danypops/pi-packed 0.3.0 → 0.4.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/README.md CHANGED
@@ -10,9 +10,9 @@ inside the supervised Bun daemon.
10
10
 
11
11
  ```text
12
12
  ┌─ Pi extension (Node-compatible) ──────────────┐
13
- │ pkg_search · pkg_info · pkg_install
14
- │ /packages · /packed security settings
15
- policy-driven approval · no Bun/SQLite access
13
+ │ pkg_search · pkg_info · pkg_install · pkg_remove
14
+ │ /packages · /packed permission settings
15
+ operation-aware approval · no Bun/SQLite access
16
16
  └──────────────────┬────────────────────────────┘
17
17
  │ authenticated loopback HTTP
18
18
  ┌─ packed.service (Bun) ────────────────────────┐
@@ -37,7 +37,7 @@ packed installed --json
37
37
  /packages
38
38
  ```
39
39
 
40
- Packages execute arbitrary code. `pkg_install` uses daemon-owned security settings with secure default `installApproval: always`. Open `/packed` to choose **Always require approval** or explicitly opt out with **Never require approval**. The unsafe opt-out allows agent installs without an interactive UI. `/packages` remains the package browser.
40
+ Packages execute arbitrary code and mutate Pi settings/install roots. One daemon-owned operation policy classifies every public package operation. Install, update, remove, and security-setting changes require explicit approval by default (`mutationApproval: always`); search, info, installed, catalog, and update-status reads are bounded reads, while mirror refresh is classified maintenance. Open `/packed` to retain the recommended approval policy or deliberately choose the unsafe **Never require mutation approval** opt-out. `/packages` uses the same policy for updates and removals.
41
41
 
42
42
  ## CLI
43
43
 
@@ -49,14 +49,14 @@ Packages execute arbitrary code. `pkg_install` uses daemon-owned security settin
49
49
  | `packed mirror [--json]` | Refresh the SQLite package index |
50
50
  | `packed installed [--json]` | Read Pi's installed package declarations |
51
51
  | `packed catalog [--json]` | Inspect the local package index |
52
- | `packed install <source> [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
53
- | `packed remove <name> [--json]` | Authenticated daemon removal by bare npm name |
54
- | `packed security [always\|never] [--json]` | Read or set the install approval policy |
52
+ | `packed install <source> [--approve] [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
53
+ | `packed remove <name> [--approve] [--json]` | Authenticated daemon removal by bare npm name |
54
+ | `packed security [always\|never] [--approve] [--json]` | Read or set the package mutation approval policy |
55
55
  | `packed serve` | Run the loopback daemon |
56
56
  | `packed service` | Print the systemd user unit |
57
57
  | `packed version` | Print the package/service version |
58
58
 
59
- Install/remove JSON results are stable objects:
59
+ Guarded CLI mutations require `--approve` under the secure default. This is pi-packed mutation authorization, distinct from Pi's project-trust `--approve` semantics. Install/remove JSON results are stable objects:
60
60
 
61
61
  ```json
62
62
  {"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
@@ -77,11 +77,11 @@ The daemon listens on loopback only.
77
77
  | `GET` | `/info?name=` |
78
78
  | `GET` | `/installed` |
79
79
  | `GET` | `/security` |
80
- | `POST` | `/security` with `{ "installApproval": "always" | "never" }` |
80
+ | `POST` | `/security` with `{ "mutationApproval": "always" | "never", "approved": true }` |
81
81
  | `GET` | `/updates` |
82
82
  | `GET` | `/catalog` |
83
- | `POST` | `/install` with `{ "source": "..." }` |
84
- | `POST` | `/remove` with `{ "name": "..." }` |
83
+ | `POST` | `/install` with `{ "source": "...", "approved": true }` |
84
+ | `POST` | `/remove` with `{ "name": "...", "approved": true }` |
85
85
 
86
86
  State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
87
87
  `updates.json`, `security.json`, and `packed.db`. Relevant environment variables:
@@ -102,6 +102,7 @@ State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
102
102
  loopback API and reconnect after daemon restarts.
103
103
  - **Ports and adapters:** registry and installer ports keep policy independent
104
104
  from npm, SQLite, subprocess, HTTP, and UI adapters.
105
+ - **Operation-aware authorization:** one policy matrix classifies reads, maintenance, code execution, settings mutation, and security mutation; guarded daemon routes reject missing approval with stable `approval_required` errors.
105
106
  - **Allowlisted mutation input:** package sources and names reject shell
106
107
  metacharacters before reaching the installer.
107
108
  - **Bounded requests:** daemon calls use timeouts and return structured errors
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { PackageDaemonPort as ClientPackageDaemonPort } from "../../src/client.ts";
10
10
  import type { InstalledPkg, Pkg, PkgInfo, UpdateEntry } from "../../src/ports.ts";
11
- import type { InstallApproval, SecuritySettings } from "../../src/security.ts";
11
+ import type { MutationApproval, SecuritySettings } from "../../src/security.ts";
12
12
 
13
13
  export type { InstalledPkg, UpdateEntry };
14
14
  export type PackageInfo = PkgInfo;
@@ -27,9 +27,9 @@ export interface Natives {
27
27
  installed(): Promise<InstalledPkg[]>;
28
28
  updates(): Promise<UpdateEntry[]>;
29
29
  security(): Promise<SecuritySettings>;
30
- setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
31
- install(source: string): Promise<string>;
32
- remove(name: string): Promise<string>;
30
+ setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
31
+ install(source: string, approved?: boolean): Promise<string>;
32
+ remove(name: string, approved?: boolean): Promise<string>;
33
33
  }
34
34
 
35
35
  export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
@@ -62,8 +62,8 @@ export async function createNatives(connect: PackageDaemonConnector = connectDef
62
62
  installed: () => call((daemon) => daemon.installed()),
63
63
  updates: () => call((daemon) => daemon.updates()),
64
64
  security: () => call((daemon) => daemon.security()),
65
- setInstallApproval: (value) => call((daemon) => daemon.setInstallApproval(value)),
66
- install: (source) => call((daemon) => daemon.install(source)),
67
- remove: (name) => call((daemon) => daemon.remove(name)),
65
+ setMutationApproval: (value, approved) => call((daemon) => daemon.setMutationApproval(value, approved)),
66
+ install: (source, approved) => call((daemon) => daemon.install(source, approved)),
67
+ remove: (name, approved) => call((daemon) => daemon.remove(name, approved)),
68
68
  };
69
69
  }
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import type { InstallApproval } from "../../src/security.ts";
2
+ import type { MutationApproval } from "../../src/security.ts";
3
3
  import type { Natives } from "./packed.ts";
4
4
 
5
- const OPTIONS: Array<{ value: InstallApproval; label: string }> = [
6
- { value: "always", label: "Always require approval (recommended)" },
7
- { value: "never", label: "Never require approval (unsafe opt-out)" },
5
+ const OPTIONS: Array<{ value: MutationApproval; label: string }> = [
6
+ { value: "always", label: "Always require mutation approval (recommended)" },
7
+ { value: "never", label: "Never require mutation approval (unsafe opt-out)" },
8
8
  ];
9
9
 
10
10
  export async function showPackedSettings(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
@@ -15,17 +15,24 @@ export async function showPackedSettings(ctx: ExtensionCommandContext, natives:
15
15
  try {
16
16
  const current = await natives.security();
17
17
  const choice = await ctx.ui.select(
18
- `Package install approval · current: ${current.installApproval}`,
18
+ `Package mutation approval · current: ${current.mutationApproval}`,
19
19
  [...OPTIONS.map(({ label }) => label), "Cancel"],
20
20
  );
21
21
  const selected = OPTIONS.find(({ label }) => label === choice);
22
- if (!selected || selected.value === current.installApproval) return;
23
- const updated = await natives.setInstallApproval(selected.value);
22
+ if (!selected || selected.value === current.mutationApproval) return;
23
+ const approved = await ctx.ui.confirm(
24
+ "Change package mutation approval",
25
+ selected.value === "never"
26
+ ? "Disable confirmation for install, update, remove, and package security changes? Packages can execute arbitrary code."
27
+ : "Restore confirmation for install, update, remove, and package security changes?",
28
+ );
29
+ if (!approved) return;
30
+ const updated = await natives.setMutationApproval(selected.value, true);
24
31
  ctx.ui.notify(
25
- updated.installApproval === "always"
26
- ? "Package installs now require confirmation."
27
- : "Package install confirmation disabled. Packages can execute arbitrary code.",
28
- updated.installApproval === "always" ? "info" : "warning",
32
+ updated.mutationApproval === "always"
33
+ ? "Package mutations now require confirmation."
34
+ : "Package mutation confirmation disabled. Packages can execute arbitrary code.",
35
+ updated.mutationApproval === "always" ? "info" : "warning",
29
36
  );
30
37
  } catch (error) {
31
38
  ctx.ui.notify(`packed security settings failed: ${error instanceof Error ? error.message : error}`, "error");
@@ -1,61 +1,88 @@
1
- /**
2
- * tools.ts — agent-facing tools. Thin by design: descriptions teach the
3
- * agent the packed CLI surface; logic lives in the Bun service.
4
- */
1
+ /** Agent-facing package tools over the authenticated daemon. */
5
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
3
  import { Type } from "typebox";
4
+ import { packagePermissionDecision, type PackageOperation } from "../../src/security.ts";
7
5
  import type { Natives } from "./packed.js";
8
6
 
9
7
  function text(t: string, details: Record<string, unknown> = {}) {
10
8
  return { content: [{ type: "text" as const, text: t }], details };
11
9
  }
12
10
 
11
+ type ApprovalContext = {
12
+ hasUI: boolean;
13
+ ui: { confirm(title: string, message: string): Promise<boolean> };
14
+ };
15
+
16
+ export async function approvePackageOperation(
17
+ operation: PackageOperation,
18
+ command: string,
19
+ natives: Pick<Natives, "security">,
20
+ ctx: ApprovalContext,
21
+ ): Promise<{ allowed: boolean; approved: boolean; message?: string }> {
22
+ const settings = await natives.security();
23
+ const decision = packagePermissionDecision(settings, operation);
24
+ if (!decision.approvalRequired) return { allowed: true, approved: false };
25
+ if (!ctx.hasUI) {
26
+ return {
27
+ allowed: false,
28
+ approved: false,
29
+ message: `${operation} requires interactive approval; change mutationApproval in /packed only to deliberately opt out.`,
30
+ };
31
+ }
32
+ const approved = await ctx.ui.confirm(
33
+ `${operation[0]!.toUpperCase()}${operation.slice(1)} Pi package`,
34
+ `Run: ${command}\n\nThis operation can execute package code or mutate Pi settings/install roots. Continue?`,
35
+ );
36
+ return approved ? { allowed: true, approved: true } : { allowed: false, approved: false, message: `${operation} cancelled by user.` };
37
+ }
38
+
13
39
  export async function installPackageWithPolicy(
14
40
  source: string,
15
41
  natives: Pick<Natives, "security" | "install">,
16
- ctx: { hasUI: boolean; ui: { confirm(title: string, message: string): Promise<boolean> } },
42
+ ctx: ApprovalContext,
17
43
  ) {
18
44
  try {
19
- const { installApproval } = await natives.security();
20
- if (installApproval === "always") {
21
- if (!ctx.hasUI) return text("pkg_install requires interactive approval; change installApproval in /packed to opt out.");
22
- const ok = await ctx.ui.confirm(
23
- "Install Pi package",
24
- `Run: pi install ${source}\n\nPackages execute arbitrary code. Continue?`,
25
- );
26
- if (!ok) return text("Install cancelled by user.");
27
- }
28
- const out = await natives.install(source);
45
+ const approval = await approvePackageOperation("install", `pi install ${source}`, natives, ctx);
46
+ if (!approval.allowed) return text(approval.message ?? "install denied");
47
+ const out = await natives.install(source, approval.approved);
29
48
  return text(out || `Installed ${source}. Reload with /reload to activate.`);
30
49
  } catch (error) {
31
50
  return text(`install failed: ${error instanceof Error ? error.message : error}`);
32
51
  }
33
52
  }
34
53
 
54
+ export async function removePackageWithPolicy(
55
+ name: string,
56
+ natives: Pick<Natives, "security" | "remove">,
57
+ ctx: ApprovalContext,
58
+ ) {
59
+ try {
60
+ const approval = await approvePackageOperation("remove", `pi remove npm:${name}`, natives, ctx);
61
+ if (!approval.allowed) return text(approval.message ?? "remove denied");
62
+ const out = await natives.remove(name, approval.approved);
63
+ return text(out || `Removed ${name}. Reload with /reload to deactivate.`);
64
+ } catch (error) {
65
+ return text(`remove failed: ${error instanceof Error ? error.message : error}`);
66
+ }
67
+ }
68
+
35
69
  export function registerTools(pi: ExtensionAPI, natives: Natives): void {
36
70
  pi.registerTool({
37
71
  name: "pkg_search",
38
72
  label: "Pi Package Search",
39
- description:
40
- "Search Pi packages (extensions, skills, themes, prompts) on the npm registry. " +
41
- "Scoped to the pi-package keyword automatically. Returns name, version, description.",
73
+ description: "Search Pi packages on npm. Bounded read operation; defaults to 10 results and caps at 50.",
42
74
  parameters: Type.Object({
43
75
  query: Type.String({ description: "Search terms, e.g. 'lsp' or 'telegram'" }),
44
76
  limit: Type.Optional(Type.Number({ description: "Max results (default 10, max 50)" })),
45
77
  }),
46
- async execute(_id, params, _signal, _onUpdate, _ctx) {
78
+ async execute(_id, params) {
47
79
  try {
48
80
  const r = await natives.search(params.query, params.limit ?? 10);
49
81
  if (r.results.length === 0) return text(`No Pi packages found for "${params.query}".`);
50
- const lines = r.results.map(
51
- (p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`,
52
- );
53
- return text(
54
- `Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`,
55
- { results: r.results, total: r.total },
56
- );
57
- } catch (e) {
58
- return text(`pkg_search failed: ${e instanceof Error ? e.message : e}`);
82
+ const lines = r.results.map((p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`);
83
+ return text(`Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`, { results: r.results, total: r.total });
84
+ } catch (error) {
85
+ return text(`pkg_search failed: ${error instanceof Error ? error.message : error}`);
59
86
  }
60
87
  },
61
88
  });
@@ -63,13 +90,9 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
63
90
  pi.registerTool({
64
91
  name: "pkg_info",
65
92
  label: "Pi Package Info",
66
- description:
67
- "Show details for a Pi package: latest version, description, repository, " +
68
- "declared pi resources (extensions/skills/themes/prompts), size, license.",
69
- parameters: Type.Object({
70
- name: Type.String({ description: "npm package name, e.g. 'pi-lsp' or '@scope/pkg'" }),
71
- }),
72
- async execute(_id, params, _signal, _onUpdate, _ctx) {
93
+ description: "Show bounded metadata and declared Pi resources for one package.",
94
+ parameters: Type.Object({ name: Type.String({ description: "npm package name" }) }),
95
+ async execute(_id, params) {
73
96
  try {
74
97
  const info = await natives.info(params.name);
75
98
  const lines = [
@@ -82,8 +105,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
82
105
  info.modified ? `modified: ${info.modified}` : "",
83
106
  ].filter(Boolean);
84
107
  return text(lines.join("\n"), { info });
85
- } catch (e) {
86
- return text(`pkg_info failed: ${e instanceof Error ? e.message : e}`);
108
+ } catch (error) {
109
+ return text(`pkg_info failed: ${error instanceof Error ? error.message : error}`);
87
110
  }
88
111
  },
89
112
  });
@@ -91,14 +114,20 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
91
114
  pi.registerTool({
92
115
  name: "pkg_install",
93
116
  label: "Pi Package Install",
94
- description:
95
- "Install a Pi package (pi install). Supports npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], " +
96
- "or https:// URLs. Packages execute arbitrary code. Approval follows the installApproval policy configured in /packed (secure default: always).",
97
- parameters: Type.Object({
98
- source: Type.String({ description: "e.g. 'npm:pi-lsp', 'npm:@scope/pkg@1.2.3', 'git:github.com/u/r@v1'" }),
99
- }),
117
+ description: "Install a Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
118
+ parameters: Type.Object({ source: Type.String({ description: "npm:, git:, or https source" }) }),
100
119
  async execute(_id, params, _signal, _onUpdate, ctx) {
101
120
  return installPackageWithPolicy(params.source, natives, ctx);
102
121
  },
103
122
  });
123
+
124
+ pi.registerTool({
125
+ name: "pkg_remove",
126
+ label: "Pi Package Remove",
127
+ description: "Remove an installed npm Pi package through the authenticated daemon. Operation-aware approval is secure by default.",
128
+ parameters: Type.Object({ name: Type.String({ description: "bare npm name, e.g. pi-lsp or @scope/pkg" }) }),
129
+ async execute(_id, params, _signal, _onUpdate, ctx) {
130
+ return removePackageWithPolicy(params.name, natives, ctx);
131
+ },
132
+ });
104
133
  }
@@ -10,6 +10,7 @@ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earend
10
10
  import { filterRows, mergeRows, nextMode, visibleRows } from "./model.js";
11
11
  import type { Row, ViewMode } from "./model.js";
12
12
  import type { Natives } from "./packed.js";
13
+ import { approvePackageOperation } from "./tools.js";
13
14
 
14
15
  interface PanelAction {
15
16
  type: "menu" | "refresh";
@@ -64,9 +65,14 @@ export async function showPackages(ctx: ExtensionCommandContext, natives: Native
64
65
  );
65
66
 
66
67
  if (choice?.startsWith("Update")) {
67
- ctx.ui.notify(`Updating ${row.name}…`, "info");
68
68
  try {
69
- await natives.install(`npm:${row.name}@${row.latest}`);
69
+ const approval = await approvePackageOperation("update", `pi update --extension npm:${row.name}`, natives, ctx);
70
+ if (!approval.allowed) {
71
+ ctx.ui.notify(approval.message ?? "update denied", "warning");
72
+ continue;
73
+ }
74
+ ctx.ui.notify(`Updating ${row.name}…`, "info");
75
+ await natives.install(`npm:${row.name}@${row.latest}`, approval.approved);
70
76
  ctx.ui.notify(`Updated ${row.name} to ${row.latest} (takes effect after /reload)`, "info");
71
77
  row.version = row.latest ?? row.version;
72
78
  row.hasUpdate = false;
@@ -74,15 +80,17 @@ export async function showPackages(ctx: ExtensionCommandContext, natives: Native
74
80
  ctx.ui.notify(`update failed: ${e instanceof Error ? e.message : e}`, "error");
75
81
  }
76
82
  } else if (choice === "Remove") {
77
- const sure = await ctx.ui.confirm("Remove package", `pi remove npm:${row.name}?`);
78
- if (sure) {
79
- try {
80
- await natives.remove(row.name);
83
+ try {
84
+ const approval = await approvePackageOperation("remove", `pi remove npm:${row.name}`, natives, ctx);
85
+ if (approval.allowed) {
86
+ await natives.remove(row.name, approval.approved);
81
87
  ctx.ui.notify(`Removed ${row.name}`, "info");
82
88
  rows = rows.filter((r) => r.name !== row.name);
83
- } catch (e) {
84
- ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
89
+ } else {
90
+ ctx.ui.notify(approval.message ?? "remove denied", "warning");
85
91
  }
92
+ } catch (e) {
93
+ ctx.ui.notify(`remove failed: ${e instanceof Error ? e.message : e}`, "error");
86
94
  }
87
95
  }
88
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Package service for the Pi agent: search/info/install/updates + /packages TUI, backed by a long-running Bun service",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/cli.ts CHANGED
@@ -11,15 +11,21 @@ import { checkUpdates } from "./watcher.ts";
11
11
  import { syncCatalog } from "./catalog.ts";
12
12
  import { openDb, searchLocal, catalogList, getSyncMeta, latestVersion, dbPath } from "./db.ts";
13
13
  import { NAME_RE, defaultPiBin } from "./install.ts";
14
- import type { InstallApproval, SecuritySettingsPort } from "./security.ts";
14
+ import {
15
+ assertPackagePermission,
16
+ type MutationApproval,
17
+ type PackageOperation,
18
+ type SecuritySettingsPort,
19
+ } from "./security.ts";
15
20
 
16
21
  function defaultPiBinForUnit(): string | undefined {
17
22
  const b = defaultPiBin();
18
23
  return b === "pi" ? undefined : b; // bare name needs no pin
19
24
  }
20
25
  import {
21
- VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
26
+ SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, NPM_REGISTRY_BASE, SEARCH_PAGE_SIZE, MIRROR_PAGE_DELAY_MS,
22
27
  } from "./constants.ts";
28
+ import { VERSION } from "./version.ts";
23
29
 
24
30
  const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
25
31
 
@@ -32,9 +38,9 @@ usage:
32
38
  packed mirror [--json] sync upstream into the local SQLite index
33
39
  packed installed [--json] installed pi packages
34
40
  packed catalog [--json] local package index (apt-cache stats)
35
- packed install <source> [--json] pi install npm:|git:|https://… via daemon
36
- packed remove <name> [--json] remove by bare npm name via daemon
37
- packed security [always|never] [--json] read or set install approval policy
41
+ packed install <source> [--approve] [--json] pi install npm:|git:|https://… via daemon
42
+ packed remove <name> [--approve] [--json] remove by bare npm name via daemon
43
+ packed security [always|never] [--approve] [--json] read or set mutation approval policy
38
44
  packed serve run the long-running daemon
39
45
  packed service print a systemd user unit
40
46
  packed version print version
@@ -61,16 +67,18 @@ interface Flags {
61
67
  limit: number;
62
68
  cached: boolean;
63
69
  offline: boolean;
70
+ approved: boolean;
64
71
  }
65
72
 
66
73
  function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
67
- const flags: Flags = { json: false, limit: SEARCH_DEFAULT_LIMIT, cached: false, offline: false };
74
+ const flags: Flags = { json: false, limit: SEARCH_DEFAULT_LIMIT, cached: false, offline: false, approved: false };
68
75
  const pos: string[] = [];
69
76
  for (let i = 0; i < rest.length; i++) {
70
77
  const a = rest[i]!;
71
78
  if (a === "--json") flags.json = true;
72
79
  else if (a === "--cached") flags.cached = true;
73
80
  else if (a === "--offline") flags.offline = true;
81
+ else if (a === "--approve") flags.approved = true;
74
82
  else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
75
83
  else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
76
84
  else pos.push(a);
@@ -84,6 +92,17 @@ const ok = (out: string): CliResult => ({ code: 0, out });
84
92
  const fail = (out: string, code = 1): CliResult => ({ code, out });
85
93
  const usageErr = (out: string): CliResult => ({ code: 2, out });
86
94
 
95
+ const PACKAGE_COMMAND_OPERATIONS: Record<string, PackageOperation | undefined> = {
96
+ search: "search",
97
+ info: "info",
98
+ installed: "installed",
99
+ catalog: "catalog",
100
+ updates: "updates",
101
+ mirror: "mirror",
102
+ install: "install",
103
+ remove: "remove",
104
+ };
105
+
87
106
  const commands: Record<string, { usage: string; run: Command }> = {
88
107
  search: {
89
108
  usage: "packed search <query> [--offline] [--limit N] [--json]",
@@ -193,7 +212,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
193
212
  const source = pos[0] ?? "";
194
213
  if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
195
214
  try {
196
- const output = await d.inst.install(source);
215
+ const output = await d.inst.install(source, { approved: flags.approved });
197
216
  return flags.json ? ok(`${JSON.stringify({ ok: true, source, output })}\n`) : ok(`${output}\n`);
198
217
  } catch (e) {
199
218
  const error = e instanceof Error ? e.message : String(e);
@@ -210,11 +229,11 @@ const commands: Record<string, { usage: string; run: Command }> = {
210
229
  return usageErr(`usage: ${commands["security"]!.usage}\n`);
211
230
  }
212
231
  const settings = requested
213
- ? await d.security.setInstallApproval(requested as InstallApproval)
232
+ ? await d.security.setMutationApproval(requested as MutationApproval, { approved: flags.approved })
214
233
  : await d.security.security();
215
234
  return flags.json
216
235
  ? ok(`${JSON.stringify(settings)}\n`)
217
- : ok(`install approval: ${settings.installApproval}\n`);
236
+ : ok(`package mutation approval: ${settings.mutationApproval}\n`);
218
237
  },
219
238
  },
220
239
 
@@ -224,7 +243,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
224
243
  const name = pos[0] ?? "";
225
244
  if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
226
245
  try {
227
- const output = await d.inst.remove(`npm:${name}`);
246
+ const output = await d.inst.remove(`npm:${name}`, { approved: flags.approved });
228
247
  return flags.json ? ok(`${JSON.stringify({ ok: true, name, output })}\n`) : ok(`${output}\n`);
229
248
  } catch (e) {
230
249
  const error = e instanceof Error ? e.message : String(e);
@@ -280,6 +299,15 @@ export async function cliRun(args: string[], d: CliDeps): Promise<CliResult> {
280
299
  if (!cmd) return usageErr(`unknown command "${name}"\n${USAGE}`);
281
300
  const { flags, pos } = parseFlags(rest);
282
301
  try {
302
+ const validMutationInput = name === "install"
303
+ ? SOURCE_RE.test(pos[0] ?? "")
304
+ : name === "remove" ? NAME_RE.test(pos[0] ?? "")
305
+ : name === "security" ? (pos[0] === undefined || pos[0] === "always" || pos[0] === "never")
306
+ : true;
307
+ const operation = name === "security"
308
+ ? (pos[0] === undefined ? "security.read" : "security.write")
309
+ : PACKAGE_COMMAND_OPERATIONS[name];
310
+ if (operation && validMutationInput) assertPackagePermission(await d.security.security(), operation, flags.approved);
283
311
  return await cmd.run(rest, d, flags, pos);
284
312
  } catch (e) {
285
313
  return fail(`${name} failed: ${e instanceof Error ? e.message : e}\n`);
package/src/client.ts CHANGED
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import type { InstalledPkg, Installer, PkgInfo, Registry, SearchPage, UpdateEntry, UpdatesSnapshot } from "./ports.ts";
9
9
  import { HttpRegistry } from "./registry.ts";
10
10
  import { DAEMON_HOST, PROBE_TIMEOUT_MS, REGISTRY_FETCH_TIMEOUT_MS, PORT_FILE, TOKEN_FILE } from "./constants.ts";
11
- import type { InstallApproval, SecuritySettings } from "./security.ts";
11
+ import type { MutationApproval, SecuritySettings } from "./security.ts";
12
12
 
13
13
  export type FetchTransport = (request: Request) => Promise<Response>;
14
14
 
@@ -18,9 +18,9 @@ export interface PackageDaemonPort {
18
18
  installed(): Promise<InstalledPkg[]>;
19
19
  updates(): Promise<UpdateEntry[]>;
20
20
  security(): Promise<SecuritySettings>;
21
- setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
22
- install(source: string): Promise<string>;
23
- remove(name: string): Promise<string>;
21
+ setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
22
+ install(source: string, approved?: boolean): Promise<string>;
23
+ remove(name: string, approved?: boolean): Promise<string>;
24
24
  }
25
25
 
26
26
  interface MutationResponse {
@@ -92,23 +92,23 @@ export class PackageDaemonClient implements PackageDaemonPort {
92
92
  return this.request("/security");
93
93
  }
94
94
 
95
- setInstallApproval(installApproval: InstallApproval): Promise<SecuritySettings> {
96
- return this.request("/security", { method: "POST", body: JSON.stringify({ installApproval }) });
95
+ setMutationApproval(mutationApproval: MutationApproval, approved = false): Promise<SecuritySettings> {
96
+ return this.request("/security", { method: "POST", body: JSON.stringify({ mutationApproval, approved }) });
97
97
  }
98
98
 
99
- async install(source: string): Promise<string> {
99
+ async install(source: string, approved = false): Promise<string> {
100
100
  const result = await this.request<MutationResponse>("/install", {
101
101
  method: "POST",
102
- body: JSON.stringify({ source }),
102
+ body: JSON.stringify({ source, approved }),
103
103
  });
104
104
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to install ${source}`, "install");
105
105
  return result.output;
106
106
  }
107
107
 
108
- async remove(name: string): Promise<string> {
108
+ async remove(name: string, approved = false): Promise<string> {
109
109
  const result = await this.request<MutationResponse>("/remove", {
110
110
  method: "POST",
111
- body: JSON.stringify({ name }),
111
+ body: JSON.stringify({ name, approved }),
112
112
  });
113
113
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "remove");
114
114
  return result.output;
@@ -118,15 +118,15 @@ export class PackageDaemonClient implements PackageDaemonPort {
118
118
  export class PackageDaemonInstaller implements Installer {
119
119
  constructor(private readonly client: PackageDaemonClient) {}
120
120
 
121
- install(source: string): Promise<string> {
122
- return this.client.install(source);
121
+ install(source: string, options?: { approved?: boolean }): Promise<string> {
122
+ return this.client.install(source, options?.approved);
123
123
  }
124
124
 
125
- remove(source: string): Promise<string> {
125
+ remove(source: string, options?: { approved?: boolean }): Promise<string> {
126
126
  if (!source.startsWith("npm:") || source.length <= 4) {
127
127
  throw new PackageDaemonError("daemon package removal requires an npm: source", "remove");
128
128
  }
129
- return this.client.remove(source.slice(4));
129
+ return this.client.remove(source.slice(4), options?.approved);
130
130
  }
131
131
  }
132
132
 
@@ -135,20 +135,20 @@ export class DaemonBackedSecurity {
135
135
  async security(): Promise<SecuritySettings> {
136
136
  return (await connectPackageDaemon(this.stateDirectory)).security();
137
137
  }
138
- async setInstallApproval(value: InstallApproval): Promise<SecuritySettings> {
139
- return (await connectPackageDaemon(this.stateDirectory)).setInstallApproval(value);
138
+ async setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings> {
139
+ return (await connectPackageDaemon(this.stateDirectory)).setMutationApproval(value, options?.approved);
140
140
  }
141
141
  }
142
142
 
143
143
  export class DaemonBackedInstaller implements Installer {
144
144
  constructor(private readonly stateDirectory: string) {}
145
145
 
146
- async install(source: string): Promise<string> {
147
- return (await connectPackageDaemon(this.stateDirectory)).install(source);
146
+ async install(source: string, options?: { approved?: boolean }): Promise<string> {
147
+ return (await connectPackageDaemon(this.stateDirectory)).install(source, options?.approved);
148
148
  }
149
149
 
150
- async remove(source: string): Promise<string> {
151
- return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source);
150
+ async remove(source: string, options?: { approved?: boolean }): Promise<string> {
151
+ return new PackageDaemonInstaller(await connectPackageDaemon(this.stateDirectory)).remove(source, options);
152
152
  }
153
153
  }
154
154
 
package/src/constants.ts CHANGED
@@ -31,7 +31,6 @@ export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
31
31
  export const WATCHDOG_TICK_MS = 15_000;
32
32
 
33
33
  // --- Identity ---
34
- export const VERSION = "0.3.0";
35
34
 
36
35
  // --- State-dir file names ---
37
36
  export const TOKEN_FILE = "token";
package/src/install.ts CHANGED
@@ -23,11 +23,11 @@ export class ExecInstaller implements Installer {
23
23
  return out;
24
24
  }
25
25
 
26
- install(source: string): Promise<string> {
26
+ install(source: string, _options?: { approved?: boolean }): Promise<string> {
27
27
  return this.run(["install", source]);
28
28
  }
29
29
 
30
- remove(source: string): Promise<string> {
30
+ remove(source: string, _options?: { approved?: boolean }): Promise<string> {
31
31
  return this.run(["remove", source]);
32
32
  }
33
33
  }
package/src/ports.ts CHANGED
@@ -39,8 +39,8 @@ export interface Registry {
39
39
 
40
40
  /** Driven port: pi CLI mutations. */
41
41
  export interface Installer {
42
- install(source: string): Promise<string>;
43
- remove(source: string): Promise<string>;
42
+ install(source: string, options?: { approved?: boolean }): Promise<string>;
43
+ remove(source: string, options?: { approved?: boolean }): Promise<string>;
44
44
  }
45
45
 
46
46
  export interface InstalledPkg {
package/src/security.ts CHANGED
@@ -2,21 +2,82 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { SECURITY_FILE } from "./constants.ts";
4
4
 
5
- export const INSTALL_APPROVAL_VALUES = ["always", "never"] as const;
6
- export type InstallApproval = typeof INSTALL_APPROVAL_VALUES[number];
7
- export interface SecuritySettings { installApproval: InstallApproval }
5
+ export const MUTATION_APPROVAL_VALUES = ["always", "never"] as const;
6
+ export type MutationApproval = typeof MUTATION_APPROVAL_VALUES[number];
7
+
8
+ export const PACKAGE_OPERATIONS = [
9
+ "search",
10
+ "info",
11
+ "installed",
12
+ "catalog",
13
+ "updates",
14
+ "security.read",
15
+ "mirror",
16
+ "install",
17
+ "update",
18
+ "remove",
19
+ "security.write",
20
+ ] as const;
21
+ export type PackageOperation = typeof PACKAGE_OPERATIONS[number];
22
+ export type PackageOperationClassification = "read" | "maintenance" | "code-execution" | "settings-mutation" | "security-mutation";
23
+
24
+ export interface SecuritySettings { mutationApproval: MutationApproval }
8
25
  export interface SecuritySettingsPort {
9
26
  security(): Promise<SecuritySettings>;
10
- setInstallApproval(value: InstallApproval): Promise<SecuritySettings>;
27
+ setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings>;
28
+ }
29
+
30
+ export interface PackagePermissionDecision {
31
+ operation: PackageOperation;
32
+ classification: PackageOperationClassification;
33
+ approvalRequired: boolean;
11
34
  }
12
35
 
13
- export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { installApproval: "always" };
36
+ export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { mutationApproval: "always" };
37
+
38
+ const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification> = {
39
+ search: "read",
40
+ info: "read",
41
+ installed: "read",
42
+ catalog: "read",
43
+ updates: "read",
44
+ "security.read": "read",
45
+ mirror: "maintenance",
46
+ install: "code-execution",
47
+ update: "code-execution",
48
+ remove: "settings-mutation",
49
+ "security.write": "security-mutation",
50
+ };
51
+
52
+ export class PackageApprovalRequiredError extends Error {
53
+ readonly code = "approval_required";
54
+ constructor(readonly operation: PackageOperation) {
55
+ super(`approval required for package operation ${operation}`);
56
+ this.name = "PackageApprovalRequiredError";
57
+ }
58
+ }
59
+
60
+ export function packagePermissionDecision(settings: SecuritySettings, operation: PackageOperation): PackagePermissionDecision {
61
+ const classification = CLASSIFICATIONS[operation];
62
+ const guarded = classification === "code-execution" || classification === "settings-mutation" || classification === "security-mutation";
63
+ return { operation, classification, approvalRequired: guarded && settings.mutationApproval === "always" };
64
+ }
65
+
66
+ export function assertPackagePermission(settings: SecuritySettings, operation: PackageOperation, approved = false): void {
67
+ if (packagePermissionDecision(settings, operation).approvalRequired && !approved) {
68
+ throw new PackageApprovalRequiredError(operation);
69
+ }
70
+ }
14
71
 
15
72
  export function readSecuritySettings(stateDir: string): SecuritySettings {
16
73
  try {
17
- const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as { installApproval?: unknown };
18
- return INSTALL_APPROVAL_VALUES.includes(value.installApproval as InstallApproval)
19
- ? { installApproval: value.installApproval as InstallApproval }
74
+ const value = JSON.parse(readFileSync(join(stateDir, SECURITY_FILE), "utf8")) as {
75
+ mutationApproval?: unknown;
76
+ installApproval?: unknown;
77
+ };
78
+ const stored = value.mutationApproval ?? value.installApproval;
79
+ return MUTATION_APPROVAL_VALUES.includes(stored as MutationApproval)
80
+ ? { mutationApproval: stored as MutationApproval }
20
81
  : { ...DEFAULT_SECURITY_SETTINGS };
21
82
  } catch {
22
83
  return { ...DEFAULT_SECURITY_SETTINGS };
@@ -24,7 +85,7 @@ export function readSecuritySettings(stateDir: string): SecuritySettings {
24
85
  }
25
86
 
26
87
  export function writeSecuritySettings(stateDir: string, settings: SecuritySettings): SecuritySettings {
27
- if (!INSTALL_APPROVAL_VALUES.includes(settings.installApproval)) throw new Error("installApproval must be always or never");
88
+ if (!MUTATION_APPROVAL_VALUES.includes(settings.mutationApproval)) throw new Error("mutationApproval must be always or never");
28
89
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
29
90
  const target = join(stateDir, SECURITY_FILE);
30
91
  const temporary = `${target}.tmp`;
package/src/service.ts CHANGED
@@ -9,9 +9,17 @@ import { TTLCache } from "./cache.ts";
9
9
  import { loadUpdates } from "./watcher.ts";
10
10
  import { readInstalledPackages, defaultPiHome } from "./installed.ts";
11
11
  import { openDb, searchLocal, catalogList, getSyncMeta, dbPath } from "./db.ts";
12
- import { VERSION, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
12
+ import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "./constants.ts";
13
+ import { VERSION } from "./version.ts";
13
14
  import { createLogger } from "./log.ts";
14
- import { readSecuritySettings, writeSecuritySettings, type InstallApproval } from "./security.ts";
15
+ import {
16
+ assertPackagePermission,
17
+ PackageApprovalRequiredError,
18
+ readSecuritySettings,
19
+ writeSecuritySettings,
20
+ type MutationApproval,
21
+ type PackageOperation,
22
+ } from "./security.ts";
15
23
 
16
24
  const log = createLogger("service");
17
25
 
@@ -31,13 +39,25 @@ function json(v: unknown, init?: ResponseInit): Response {
31
39
  return Response.json(v, init);
32
40
  }
33
41
 
34
- function err(status: number, msg: string): Response {
35
- return json({ error: msg }, { status });
42
+ function err(status: number, msg: string, details: Record<string, unknown> = {}): Response {
43
+ return json({ error: msg, ...details }, { status });
36
44
  }
37
45
 
38
46
  export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
39
47
  const cache = deps.cache ?? new TTLCache();
40
48
 
49
+ function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
50
+ try {
51
+ assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved);
52
+ return undefined;
53
+ } catch (error) {
54
+ if (error instanceof PackageApprovalRequiredError) {
55
+ return err(403, error.message, { code: error.code, operation: error.operation });
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+
41
61
  async function route(req: Request): Promise<Response> {
42
62
  const url = new URL(req.url);
43
63
  const path = url.pathname;
@@ -51,16 +71,18 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
51
71
  }
52
72
 
53
73
  if (path === "/security" && req.method === "POST") {
54
- let installApproval: unknown;
74
+ let body: { mutationApproval?: unknown; approved?: unknown };
55
75
  try {
56
- installApproval = ((await req.json()) as { installApproval?: unknown }).installApproval;
76
+ body = (await req.json()) as typeof body;
57
77
  } catch {
58
78
  return err(400, "invalid security settings JSON");
59
79
  }
60
- if (installApproval !== "always" && installApproval !== "never") {
61
- return err(400, "installApproval must be always or never");
80
+ if (body.mutationApproval !== "always" && body.mutationApproval !== "never") {
81
+ return err(400, "mutationApproval must be always or never");
62
82
  }
63
- return json(writeSecuritySettings(deps.stateDir, { installApproval: installApproval as InstallApproval }));
83
+ const denied = authorize("security.write", body.approved === true);
84
+ if (denied) return denied;
85
+ return json(writeSecuritySettings(deps.stateDir, { mutationApproval: body.mutationApproval as MutationApproval }));
64
86
  }
65
87
 
66
88
  if (path === "/search" && req.method === "GET") {
@@ -100,17 +122,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
100
122
 
101
123
  if (path === "/remove" && req.method === "POST") {
102
124
  let name = "";
125
+ let approved = false;
103
126
  try {
104
- const body = (await req.json()) as { name?: unknown };
127
+ const body = (await req.json()) as { name?: unknown; approved?: unknown };
105
128
  name = String(body.name ?? "");
129
+ approved = body.approved === true;
106
130
  } catch {
107
131
  /* fall through to validation */
108
132
  }
109
133
  if (!NAME_RE.test(name)) {
110
134
  return err(400, "invalid name; want a bare npm package name");
111
135
  }
136
+ const denied = authorize("remove", approved);
137
+ if (denied) return denied;
112
138
  try {
113
- const output = await deps.inst.remove(`npm:${name}`);
139
+ const output = await deps.inst.remove(`npm:${name}`, { approved });
114
140
  return json({ ok: true, name, output });
115
141
  } catch (e) {
116
142
  return json({ ok: false, name, output: e instanceof Error ? e.message : String(e) });
@@ -119,17 +145,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
119
145
 
120
146
  if (path === "/install" && req.method === "POST") {
121
147
  let source = "";
148
+ let approved = false;
122
149
  try {
123
- const body = (await req.json()) as { source?: unknown };
150
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
124
151
  source = String(body.source ?? "");
152
+ approved = body.approved === true;
125
153
  } catch {
126
154
  /* fall through to validation */
127
155
  }
128
156
  if (!SOURCE_RE.test(source)) {
129
157
  return err(400, "invalid source; want npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], or https://…");
130
158
  }
159
+ const denied = authorize("install", approved);
160
+ if (denied) return denied;
131
161
  try {
132
- const output = await deps.inst.install(source);
162
+ const output = await deps.inst.install(source, { approved });
133
163
  return json({ ok: true, source, output });
134
164
  } catch (e) {
135
165
  return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
package/src/version.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ function packageVersion(): string {
4
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as unknown;
5
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
6
+ throw new Error("pi-packed package manifest must be an object");
7
+ }
8
+ const version = (manifest as Record<string, unknown>)["version"];
9
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
10
+ throw new Error("pi-packed package manifest has an invalid version");
11
+ }
12
+ return version;
13
+ }
14
+
15
+ /** Runtime package version; package.json is the single release source of truth. */
16
+ export const VERSION = packageVersion();