@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 +14 -12
- package/extension/src/index.ts +8 -0
- package/extension/src/packed.ts +9 -4
- package/extension/src/security-tui.ts +40 -0
- package/extension/src/tools.ts +84 -46
- package/extension/src/tui.ts +16 -8
- package/package.json +1 -1
- package/src/cli.ts +55 -7
- package/src/client.ts +35 -14
- package/src/constants.ts +1 -1
- package/src/install.ts +2 -2
- package/src/ports.ts +2 -2
- package/src/security.ts +95 -0
- package/src/service.ts +56 -8
- package/src/version.ts +16 -0
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 ·
|
|
15
|
-
│
|
|
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. `
|
|
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
|
package/extension/src/index.ts
CHANGED
|
@@ -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) => {
|
package/extension/src/packed.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
+
}
|
package/extension/src/tools.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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 (
|
|
64
|
-
return text(`pkg_info failed: ${
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
}
|
package/extension/src/tui.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
}
|
|
84
|
-
ctx.ui.notify(
|
|
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
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
|
-
|
|
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]
|
|
35
|
-
packed remove <name> [--json]
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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 {
|
package/src/security.ts
ADDED
|
@@ -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 {
|
|
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();
|