@danypops/pi-packed 0.2.1 → 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 · session_start update notification
15
- confirmation gates · no Bun/SQLite dependency
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,9 +37,7 @@ packed installed --json
37
37
  /packages
38
38
  ```
39
39
 
40
- Packages execute arbitrary code. `pkg_install` always requires interactive user
41
- confirmation before the extension sends the mutation to the authenticated
42
- daemon.
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.
43
41
 
44
42
  ## CLI
45
43
 
@@ -51,13 +49,14 @@ daemon.
51
49
  | `packed mirror [--json]` | Refresh the SQLite package index |
52
50
  | `packed installed [--json]` | Read Pi's installed package declarations |
53
51
  | `packed catalog [--json]` | Inspect the local package index |
54
- | `packed install <source> [--json]` | Authenticated daemon install for `npm:`, `git:`, or `https://` sources |
55
- | `packed remove <name> [--json]` | Authenticated daemon removal by bare npm name |
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 |
56
55
  | `packed serve` | Run the loopback daemon |
57
56
  | `packed service` | Print the systemd user unit |
58
57
  | `packed version` | Print the package/service version |
59
58
 
60
- 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:
61
60
 
62
61
  ```json
63
62
  {"ok":true,"source":"npm:pi-lsp","output":"Installed npm:pi-lsp"}
@@ -77,13 +76,15 @@ The daemon listens on loopback only.
77
76
  | `GET` | `/search?q=&limit=&offline=1` |
78
77
  | `GET` | `/info?name=` |
79
78
  | `GET` | `/installed` |
79
+ | `GET` | `/security` |
80
+ | `POST` | `/security` with `{ "mutationApproval": "always" | "never", "approved": true }` |
80
81
  | `GET` | `/updates` |
81
82
  | `GET` | `/catalog` |
82
- | `POST` | `/install` with `{ "source": "..." }` |
83
- | `POST` | `/remove` with `{ "name": "..." }` |
83
+ | `POST` | `/install` with `{ "source": "...", "approved": true }` |
84
+ | `POST` | `/remove` with `{ "name": "...", "approved": true }` |
84
85
 
85
86
  State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
86
- `updates.json`, and `packed.db`. Relevant environment variables:
87
+ `updates.json`, `security.json`, and `packed.db`. Relevant environment variables:
87
88
 
88
89
  - `PI_PACKED_HOME`
89
90
  - `PI_PACKED_PI_HOME`
@@ -101,6 +102,7 @@ State defaults to `~/.cache/pi-packed/` and contains `token`, `port`,
101
102
  loopback API and reconnect after daemon restarts.
102
103
  - **Ports and adapters:** registry and installer ports keep policy independent
103
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.
104
106
  - **Allowlisted mutation input:** package sources and names reject shell
105
107
  metacharacters before reaching the installer.
106
108
  - **Bounded requests:** daemon calls use timeouts and return structured errors
@@ -13,6 +13,7 @@ import { registerTools } from "./tools.js";
13
13
  import { showPackages } from "./tui.js";
14
14
  import { createNatives } from "./packed.js";
15
15
  import { formatUpdateNotice } from "./model.js";
16
+ import { showPackedSettings } from "./security-tui.js";
16
17
 
17
18
  // Async factory (pi awaits it): the seam creates authenticated daemon
18
19
  // clients lazily. It never executes Bun-only adapters or opens SQLite.
@@ -20,6 +21,13 @@ export default async function (pi: ExtensionAPI) {
20
21
  const natives = await createNatives();
21
22
  registerTools(pi, natives);
22
23
 
24
+ pi.registerCommand("packed", {
25
+ description: "Configure pi-packed security settings",
26
+ handler: async (_args, ctx) => {
27
+ await showPackedSettings(ctx, natives);
28
+ },
29
+ });
30
+
23
31
  pi.registerCommand("packages", {
24
32
  description: "Browse and manage installed Pi packages (pi-packed)",
25
33
  handler: async (_args, ctx) => {
@@ -8,6 +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 { MutationApproval, SecuritySettings } from "../../src/security.ts";
11
12
 
12
13
  export type { InstalledPkg, UpdateEntry };
13
14
  export type PackageInfo = PkgInfo;
@@ -25,8 +26,10 @@ export interface Natives {
25
26
  info(name: string): Promise<PackageInfo>;
26
27
  installed(): Promise<InstalledPkg[]>;
27
28
  updates(): Promise<UpdateEntry[]>;
28
- install(source: string): Promise<string>;
29
- remove(name: string): Promise<string>;
29
+ security(): Promise<SecuritySettings>;
30
+ setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
31
+ install(source: string, approved?: boolean): Promise<string>;
32
+ remove(name: string, approved?: boolean): Promise<string>;
30
33
  }
31
34
 
32
35
  export type PackageDaemonConnector = () => Promise<PackageDaemonPort>;
@@ -58,7 +61,9 @@ export async function createNatives(connect: PackageDaemonConnector = connectDef
58
61
  info: (name) => call((daemon) => daemon.info(name)),
59
62
  installed: () => call((daemon) => daemon.installed()),
60
63
  updates: () => call((daemon) => daemon.updates()),
61
- install: (source) => call((daemon) => daemon.install(source)),
62
- remove: (name) => call((daemon) => daemon.remove(name)),
64
+ security: () => call((daemon) => daemon.security()),
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)),
63
68
  };
64
69
  }
@@ -0,0 +1,40 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { MutationApproval } from "../../src/security.ts";
3
+ import type { Natives } from "./packed.ts";
4
+
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
+ ];
9
+
10
+ export async function showPackedSettings(ctx: ExtensionCommandContext, natives: Natives): Promise<void> {
11
+ if (!ctx.hasUI) {
12
+ ctx.ui.notify("/packed requires interactive mode", "warning");
13
+ return;
14
+ }
15
+ try {
16
+ const current = await natives.security();
17
+ const choice = await ctx.ui.select(
18
+ `Package mutation approval · current: ${current.mutationApproval}`,
19
+ [...OPTIONS.map(({ label }) => label), "Cancel"],
20
+ );
21
+ const selected = OPTIONS.find(({ label }) => label === choice);
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);
31
+ ctx.ui.notify(
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",
36
+ );
37
+ } catch (error) {
38
+ ctx.ui.notify(`packed security settings failed: ${error instanceof Error ? error.message : error}`, "error");
39
+ }
40
+ }
@@ -1,39 +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
+
39
+ export async function installPackageWithPolicy(
40
+ source: string,
41
+ natives: Pick<Natives, "security" | "install">,
42
+ ctx: ApprovalContext,
43
+ ) {
44
+ try {
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);
48
+ return text(out || `Installed ${source}. Reload with /reload to activate.`);
49
+ } catch (error) {
50
+ return text(`install failed: ${error instanceof Error ? error.message : error}`);
51
+ }
52
+ }
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
+
13
69
  export function registerTools(pi: ExtensionAPI, natives: Natives): void {
14
70
  pi.registerTool({
15
71
  name: "pkg_search",
16
72
  label: "Pi Package Search",
17
- description:
18
- "Search Pi packages (extensions, skills, themes, prompts) on the npm registry. " +
19
- "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.",
20
74
  parameters: Type.Object({
21
75
  query: Type.String({ description: "Search terms, e.g. 'lsp' or 'telegram'" }),
22
76
  limit: Type.Optional(Type.Number({ description: "Max results (default 10, max 50)" })),
23
77
  }),
24
- async execute(_id, params, _signal, _onUpdate, _ctx) {
78
+ async execute(_id, params) {
25
79
  try {
26
80
  const r = await natives.search(params.query, params.limit ?? 10);
27
81
  if (r.results.length === 0) return text(`No Pi packages found for "${params.query}".`);
28
- const lines = r.results.map(
29
- (p, i) => `${i + 1}. ${p.name}@${p.version}\n ${p.description ?? ""}`,
30
- );
31
- return text(
32
- `Found ${r.total} pi package(s) (showing ${r.results.length}):\n\n${lines.join("\n")}`,
33
- { results: r.results, total: r.total },
34
- );
35
- } catch (e) {
36
- 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}`);
37
86
  }
38
87
  },
39
88
  });
@@ -41,13 +90,9 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
41
90
  pi.registerTool({
42
91
  name: "pkg_info",
43
92
  label: "Pi Package Info",
44
- description:
45
- "Show details for a Pi package: latest version, description, repository, " +
46
- "declared pi resources (extensions/skills/themes/prompts), size, license.",
47
- parameters: Type.Object({
48
- name: Type.String({ description: "npm package name, e.g. 'pi-lsp' or '@scope/pkg'" }),
49
- }),
50
- 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) {
51
96
  try {
52
97
  const info = await natives.info(params.name);
53
98
  const lines = [
@@ -60,8 +105,8 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
60
105
  info.modified ? `modified: ${info.modified}` : "",
61
106
  ].filter(Boolean);
62
107
  return text(lines.join("\n"), { info });
63
- } catch (e) {
64
- 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}`);
65
110
  }
66
111
  },
67
112
  });
@@ -69,27 +114,20 @@ export function registerTools(pi: ExtensionAPI, natives: Natives): void {
69
114
  pi.registerTool({
70
115
  name: "pkg_install",
71
116
  label: "Pi Package Install",
72
- description:
73
- "Install a Pi package (pi install). Supports npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], " +
74
- "or https:// URLs. Packages execute arbitrary code — the user confirms every install.",
75
- parameters: Type.Object({
76
- source: Type.String({ description: "e.g. 'npm:pi-lsp', 'npm:@scope/pkg@1.2.3', 'git:github.com/u/r@v1'" }),
77
- }),
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" }) }),
78
119
  async execute(_id, params, _signal, _onUpdate, ctx) {
79
- if (!ctx.hasUI) {
80
- return text("pkg_install requires an interactive session (the user must confirm).");
81
- }
82
- const ok = await ctx.ui.confirm(
83
- "Install Pi package",
84
- `Run: pi install ${params.source}\n\nPackages execute arbitrary code. Continue?`,
85
- );
86
- if (!ok) return text("Install cancelled by user.");
87
- try {
88
- const out = await natives.install(params.source);
89
- return text(out || `Installed ${params.source}. Reload with /reload to activate.`);
90
- } catch (e) {
91
- return text(`install failed: ${e instanceof Error ? e.message : e}`);
92
- }
120
+ return installPackageWithPolicy(params.source, natives, ctx);
121
+ },
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);
93
131
  },
94
132
  });
95
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.2.1",
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,14 +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 {
15
+ assertPackagePermission,
16
+ type MutationApproval,
17
+ type PackageOperation,
18
+ type SecuritySettingsPort,
19
+ } from "./security.ts";
14
20
 
15
21
  function defaultPiBinForUnit(): string | undefined {
16
22
  const b = defaultPiBin();
17
23
  return b === "pi" ? undefined : b; // bare name needs no pin
18
24
  }
19
25
  import {
20
- 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,
21
27
  } from "./constants.ts";
28
+ import { VERSION } from "./version.ts";
22
29
 
23
30
  const SOURCE_RE = /^(npm:[A-Za-z0-9@._/-]+|git:[A-Za-z0-9@:._/-]+|https:\/\/[A-Za-z0-9@:._/?=&%~-]+)$/;
24
31
 
@@ -31,8 +38,9 @@ usage:
31
38
  packed mirror [--json] sync upstream into the local SQLite index
32
39
  packed installed [--json] installed pi packages
33
40
  packed catalog [--json] local package index (apt-cache stats)
34
- packed install <source> [--json] pi install npm:|git:|https://… via daemon
35
- packed remove <name> [--json] remove by bare npm name via daemon
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
36
44
  packed serve run the long-running daemon
37
45
  packed service print a systemd user unit
38
46
  packed version print version
@@ -41,6 +49,7 @@ usage:
41
49
  export interface CliDeps {
42
50
  reg: Registry;
43
51
  inst: Installer;
52
+ security: SecuritySettingsPort;
44
53
  stateDir: string;
45
54
  piHome: string;
46
55
  execPath?: string; // bun binary (defaults to process.execPath)
@@ -58,16 +67,18 @@ interface Flags {
58
67
  limit: number;
59
68
  cached: boolean;
60
69
  offline: boolean;
70
+ approved: boolean;
61
71
  }
62
72
 
63
73
  function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
64
- 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 };
65
75
  const pos: string[] = [];
66
76
  for (let i = 0; i < rest.length; i++) {
67
77
  const a = rest[i]!;
68
78
  if (a === "--json") flags.json = true;
69
79
  else if (a === "--cached") flags.cached = true;
70
80
  else if (a === "--offline") flags.offline = true;
81
+ else if (a === "--approve") flags.approved = true;
71
82
  else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
72
83
  else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
73
84
  else pos.push(a);
@@ -81,6 +92,17 @@ const ok = (out: string): CliResult => ({ code: 0, out });
81
92
  const fail = (out: string, code = 1): CliResult => ({ code, out });
82
93
  const usageErr = (out: string): CliResult => ({ code: 2, out });
83
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
+
84
106
  const commands: Record<string, { usage: string; run: Command }> = {
85
107
  search: {
86
108
  usage: "packed search <query> [--offline] [--limit N] [--json]",
@@ -190,7 +212,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
190
212
  const source = pos[0] ?? "";
191
213
  if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands["install"]!.usage}\n`);
192
214
  try {
193
- const output = await d.inst.install(source);
215
+ const output = await d.inst.install(source, { approved: flags.approved });
194
216
  return flags.json ? ok(`${JSON.stringify({ ok: true, source, output })}\n`) : ok(`${output}\n`);
195
217
  } catch (e) {
196
218
  const error = e instanceof Error ? e.message : String(e);
@@ -199,13 +221,29 @@ const commands: Record<string, { usage: string; run: Command }> = {
199
221
  },
200
222
  },
201
223
 
224
+ security: {
225
+ usage: "packed security [always|never] [--json]",
226
+ async run(_rest, d, flags, pos) {
227
+ const requested = pos[0];
228
+ if (requested !== undefined && requested !== "always" && requested !== "never") {
229
+ return usageErr(`usage: ${commands["security"]!.usage}\n`);
230
+ }
231
+ const settings = requested
232
+ ? await d.security.setMutationApproval(requested as MutationApproval, { approved: flags.approved })
233
+ : await d.security.security();
234
+ return flags.json
235
+ ? ok(`${JSON.stringify(settings)}\n`)
236
+ : ok(`package mutation approval: ${settings.mutationApproval}\n`);
237
+ },
238
+ },
239
+
202
240
  remove: {
203
241
  usage: "packed remove <name> [--json] (bare npm name, e.g. pi-lsp or @scope/pkg)",
204
242
  async run(_rest, d, flags, pos) {
205
243
  const name = pos[0] ?? "";
206
244
  if (!NAME_RE.test(name)) return usageErr(`usage: ${commands["remove"]!.usage}\n`);
207
245
  try {
208
- const output = await d.inst.remove(`npm:${name}`);
246
+ const output = await d.inst.remove(`npm:${name}`, { approved: flags.approved });
209
247
  return flags.json ? ok(`${JSON.stringify({ ok: true, name, output })}\n`) : ok(`${output}\n`);
210
248
  } catch (e) {
211
249
  const error = e instanceof Error ? e.message : String(e);
@@ -261,6 +299,15 @@ export async function cliRun(args: string[], d: CliDeps): Promise<CliResult> {
261
299
  if (!cmd) return usageErr(`unknown command "${name}"\n${USAGE}`);
262
300
  const { flags, pos } = parseFlags(rest);
263
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);
264
311
  return await cmd.run(rest, d, flags, pos);
265
312
  } catch (e) {
266
313
  return fail(`${name} failed: ${e instanceof Error ? e.message : e}\n`);
@@ -277,7 +324,7 @@ if (import.meta.main) {
277
324
  } else {
278
325
  const { stateDir } = await import("./state.ts");
279
326
  const { defaultPiHome } = await import("./installed.ts");
280
- const { DaemonBackedInstaller, resolveRegistry } = await import("./client.ts");
327
+ const { DaemonBackedSecurity, DaemonBackedInstaller, resolveRegistry } = await import("./client.ts");
281
328
  const dir = stateDir();
282
329
  // mirror talks to UPSTREAM, not the daemon cache — apt update semantics.
283
330
  const reg =
@@ -287,6 +334,7 @@ if (import.meta.main) {
287
334
  const { code, out } = await cliRun(args, {
288
335
  reg,
289
336
  inst: new DaemonBackedInstaller(dir),
337
+ security: new DaemonBackedSecurity(dir),
290
338
  stateDir: dir,
291
339
  piHome: defaultPiHome(),
292
340
  });
package/src/client.ts CHANGED
@@ -8,6 +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 { MutationApproval, SecuritySettings } from "./security.ts";
11
12
 
12
13
  export type FetchTransport = (request: Request) => Promise<Response>;
13
14
 
@@ -16,8 +17,10 @@ export interface PackageDaemonPort {
16
17
  info(name: string): Promise<PkgInfo>;
17
18
  installed(): Promise<InstalledPkg[]>;
18
19
  updates(): Promise<UpdateEntry[]>;
19
- install(source: string): Promise<string>;
20
- remove(name: string): Promise<string>;
20
+ security(): Promise<SecuritySettings>;
21
+ setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
22
+ install(source: string, approved?: boolean): Promise<string>;
23
+ remove(name: string, approved?: boolean): Promise<string>;
21
24
  }
22
25
 
23
26
  interface MutationResponse {
@@ -85,19 +88,27 @@ export class PackageDaemonClient implements PackageDaemonPort {
85
88
  return (await this.request<UpdatesSnapshot>("/updates")).updates;
86
89
  }
87
90
 
88
- async install(source: string): Promise<string> {
91
+ security(): Promise<SecuritySettings> {
92
+ return this.request("/security");
93
+ }
94
+
95
+ setMutationApproval(mutationApproval: MutationApproval, approved = false): Promise<SecuritySettings> {
96
+ return this.request("/security", { method: "POST", body: JSON.stringify({ mutationApproval, approved }) });
97
+ }
98
+
99
+ async install(source: string, approved = false): Promise<string> {
89
100
  const result = await this.request<MutationResponse>("/install", {
90
101
  method: "POST",
91
- body: JSON.stringify({ source }),
102
+ body: JSON.stringify({ source, approved }),
92
103
  });
93
104
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to install ${source}`, "install");
94
105
  return result.output;
95
106
  }
96
107
 
97
- async remove(name: string): Promise<string> {
108
+ async remove(name: string, approved = false): Promise<string> {
98
109
  const result = await this.request<MutationResponse>("/remove", {
99
110
  method: "POST",
100
- body: JSON.stringify({ name }),
111
+ body: JSON.stringify({ name, approved }),
101
112
  });
102
113
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "remove");
103
114
  return result.output;
@@ -107,27 +118,37 @@ export class PackageDaemonClient implements PackageDaemonPort {
107
118
  export class PackageDaemonInstaller implements Installer {
108
119
  constructor(private readonly client: PackageDaemonClient) {}
109
120
 
110
- install(source: string): Promise<string> {
111
- return this.client.install(source);
121
+ install(source: string, options?: { approved?: boolean }): Promise<string> {
122
+ return this.client.install(source, options?.approved);
112
123
  }
113
124
 
114
- remove(source: string): Promise<string> {
125
+ remove(source: string, options?: { approved?: boolean }): Promise<string> {
115
126
  if (!source.startsWith("npm:") || source.length <= 4) {
116
127
  throw new PackageDaemonError("daemon package removal requires an npm: source", "remove");
117
128
  }
118
- return this.client.remove(source.slice(4));
129
+ return this.client.remove(source.slice(4), options?.approved);
130
+ }
131
+ }
132
+
133
+ export class DaemonBackedSecurity {
134
+ constructor(private readonly stateDirectory: string) {}
135
+ async security(): Promise<SecuritySettings> {
136
+ return (await connectPackageDaemon(this.stateDirectory)).security();
137
+ }
138
+ async setMutationApproval(value: MutationApproval, options?: { approved?: boolean }): Promise<SecuritySettings> {
139
+ return (await connectPackageDaemon(this.stateDirectory)).setMutationApproval(value, options?.approved);
119
140
  }
120
141
  }
121
142
 
122
143
  export class DaemonBackedInstaller implements Installer {
123
144
  constructor(private readonly stateDirectory: string) {}
124
145
 
125
- async install(source: string): Promise<string> {
126
- 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);
127
148
  }
128
149
 
129
- async remove(source: string): Promise<string> {
130
- 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);
131
152
  }
132
153
  }
133
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.2.1";
35
34
 
36
35
  // --- State-dir file names ---
37
36
  export const TOKEN_FILE = "token";
@@ -39,6 +38,7 @@ export const PORT_FILE = "port";
39
38
  export const UPDATES_FILE = "updates.json";
40
39
  export const DB_FILE = "packed.db";
41
40
  export const SETTINGS_FILE = "settings.json";
41
+ export const SECURITY_FILE = "security.json";
42
42
 
43
43
  // --- Environment knobs ---
44
44
  export const ENV = {
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 {
@@ -0,0 +1,95 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { SECURITY_FILE } from "./constants.ts";
4
+
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 }
25
+ export interface SecuritySettingsPort {
26
+ security(): 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;
34
+ }
35
+
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
+ }
71
+
72
+ export function readSecuritySettings(stateDir: string): SecuritySettings {
73
+ try {
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 }
81
+ : { ...DEFAULT_SECURITY_SETTINGS };
82
+ } catch {
83
+ return { ...DEFAULT_SECURITY_SETTINGS };
84
+ }
85
+ }
86
+
87
+ export function writeSecuritySettings(stateDir: string, settings: SecuritySettings): SecuritySettings {
88
+ if (!MUTATION_APPROVAL_VALUES.includes(settings.mutationApproval)) throw new Error("mutationApproval must be always or never");
89
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
90
+ const target = join(stateDir, SECURITY_FILE);
91
+ const temporary = `${target}.tmp`;
92
+ writeFileSync(temporary, `${JSON.stringify(settings)}\n`, { mode: 0o600 });
93
+ renameSync(temporary, target);
94
+ return { ...settings };
95
+ }
package/src/service.ts CHANGED
@@ -9,8 +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";
15
+ import {
16
+ assertPackagePermission,
17
+ PackageApprovalRequiredError,
18
+ readSecuritySettings,
19
+ writeSecuritySettings,
20
+ type MutationApproval,
21
+ type PackageOperation,
22
+ } from "./security.ts";
14
23
 
15
24
  const log = createLogger("service");
16
25
 
@@ -30,13 +39,25 @@ function json(v: unknown, init?: ResponseInit): Response {
30
39
  return Response.json(v, init);
31
40
  }
32
41
 
33
- function err(status: number, msg: string): Response {
34
- return json({ error: msg }, { status });
42
+ function err(status: number, msg: string, details: Record<string, unknown> = {}): Response {
43
+ return json({ error: msg, ...details }, { status });
35
44
  }
36
45
 
37
46
  export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
38
47
  const cache = deps.cache ?? new TTLCache();
39
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
+
40
61
  async function route(req: Request): Promise<Response> {
41
62
  const url = new URL(req.url);
42
63
  const path = url.pathname;
@@ -45,6 +66,25 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
45
66
  return json({ ok: true, version: VERSION });
46
67
  }
47
68
 
69
+ if (path === "/security" && req.method === "GET") {
70
+ return json(readSecuritySettings(deps.stateDir));
71
+ }
72
+
73
+ if (path === "/security" && req.method === "POST") {
74
+ let body: { mutationApproval?: unknown; approved?: unknown };
75
+ try {
76
+ body = (await req.json()) as typeof body;
77
+ } catch {
78
+ return err(400, "invalid security settings JSON");
79
+ }
80
+ if (body.mutationApproval !== "always" && body.mutationApproval !== "never") {
81
+ return err(400, "mutationApproval must be always or never");
82
+ }
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 }));
86
+ }
87
+
48
88
  if (path === "/search" && req.method === "GET") {
49
89
  const q = url.searchParams.get("q") ?? "";
50
90
  const limit = clampLimit(Number(url.searchParams.get("limit")), SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT);
@@ -82,17 +122,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
82
122
 
83
123
  if (path === "/remove" && req.method === "POST") {
84
124
  let name = "";
125
+ let approved = false;
85
126
  try {
86
- const body = (await req.json()) as { name?: unknown };
127
+ const body = (await req.json()) as { name?: unknown; approved?: unknown };
87
128
  name = String(body.name ?? "");
129
+ approved = body.approved === true;
88
130
  } catch {
89
131
  /* fall through to validation */
90
132
  }
91
133
  if (!NAME_RE.test(name)) {
92
134
  return err(400, "invalid name; want a bare npm package name");
93
135
  }
136
+ const denied = authorize("remove", approved);
137
+ if (denied) return denied;
94
138
  try {
95
- const output = await deps.inst.remove(`npm:${name}`);
139
+ const output = await deps.inst.remove(`npm:${name}`, { approved });
96
140
  return json({ ok: true, name, output });
97
141
  } catch (e) {
98
142
  return json({ ok: false, name, output: e instanceof Error ? e.message : String(e) });
@@ -101,17 +145,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
101
145
 
102
146
  if (path === "/install" && req.method === "POST") {
103
147
  let source = "";
148
+ let approved = false;
104
149
  try {
105
- const body = (await req.json()) as { source?: unknown };
150
+ const body = (await req.json()) as { source?: unknown; approved?: unknown };
106
151
  source = String(body.source ?? "");
152
+ approved = body.approved === true;
107
153
  } catch {
108
154
  /* fall through to validation */
109
155
  }
110
156
  if (!SOURCE_RE.test(source)) {
111
157
  return err(400, "invalid source; want npm:<pkg>[@ver], git:<host>/<owner>/<repo>[@ref], or https://…");
112
158
  }
159
+ const denied = authorize("install", approved);
160
+ if (denied) return denied;
113
161
  try {
114
- const output = await deps.inst.install(source);
162
+ const output = await deps.inst.install(source, { approved });
115
163
  return json({ ok: true, source, output });
116
164
  } catch (e) {
117
165
  return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
@@ -143,7 +191,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
143
191
  return err(401, "missing or invalid bearer token");
144
192
  }
145
193
  // Cache successful GETs by URI (smart-proxy concern).
146
- if (req.method === "GET" && !["/health", "/updates", "/catalog"].includes(new URL(req.url).pathname)) {
194
+ if (req.method === "GET" && !["/health", "/updates", "/catalog", "/security"].includes(new URL(req.url).pathname)) {
147
195
  const hit = cache.get(req.url);
148
196
  if (hit) {
149
197
  log.debug("request", { path: new URL(req.url).pathname, cache: "hit", ms: Date.now() - t0 });
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();