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