pum-agent 0.2.5-beta.1 → 0.2.6-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ The following screens are real OpenTUI renders captured through `tmux`. A local
40
40
 
41
41
  ## Why PUM
42
42
 
43
- - **Compact terminal UI:** Streaming Markdown, syntax highlighting, thinking traces, tool rows, usage, cost, and Git status.
43
+ - **Compact terminal UI:** Streaming Markdown, syntax highlighting, thinking traces, tool rows, usage, cost, launch directory, and Git status.
44
44
  - **Full coding loop:** Built-in `read`, `write`, `edit`, `bash`, and atomic `apply_patch` tools.
45
45
  - **Parallel subagents:** Persistent agents work in isolated Git worktrees, communicate durably, and report lifecycle transitions to their direct spawners.
46
46
  - **Prompt control:** Steer active work, answer model questionnaires, use an ownership-aware message cache, attach clipboard images, and resume sessions with metadata-rich history.
@@ -223,10 +223,10 @@ Trigger events target one exact main or retained child session. A missing sessio
223
223
  Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
224
224
 
225
225
  - **Strict:** Run deterministic hard rules, then require a clear verifier approval.
226
- - **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls and explicit non-sensitive external reads. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
226
+ - **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls, explicit non-sensitive external reads, and narrowly validated lifecycle-disabled `npm pack` operations whose cache and output stay in approved roots. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
227
227
  - **Ask:** Show the approval popup for every checked call that passes hard rules, unless an exact session or project approval already matches. A verifier `SAFE`, unclear, error, or unavailable result still requires approval.
228
228
 
229
- Every active profile hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. Balanced permits only explicit, deterministically classified, non-sensitive external reads. These hard blocks cannot be overridden and do not open the popup. An explicit verifier `UNSAFE` verdict also blocks without a popup. The only exception is a deterministic match for direct main-agent `npm publish` or `npm dist-tag add`. The verifier category does not control this exception. The exception still requires explicit popup approval. Managed subagents cannot use the exception.
229
+ Every active profile hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. Balanced permits explicit, deterministically classified, non-sensitive external reads. It also accepts one direct `npm pack` only when lifecycle scripts are disabled, an explicit cache stays in an approved root, output stays in an approved root, and any package operand is one exact registry version; file, Git, URL, tag, range, composed, and global-install forms remain blocked. These hard blocks cannot be overridden and do not open the popup. An explicit verifier `UNSAFE` verdict also blocks without a popup. The only publication exception is a deterministic match for direct main-agent `npm publish` or `npm dist-tag add`. The verifier category does not control this exception. The exception still requires explicit popup approval. Managed subagents cannot use it.
230
230
 
231
231
  Use `/check-path list`, `/check-path add <directory>`, `/check-path remove <directory>`, or `/check-path clear` to manage up to 16 additional directory roots for the current launch project. Bash, edit, and external-trigger checks can use these roots; `apply_patch` remains project-local. Added roots are canonicalized and remain subject to credential, traversal, symlink or junction, broad-deletion, and other hard blocks.
232
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.5-beta.1",
3
+ "version": "0.2.6-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -2309,6 +2309,7 @@ export function App({
2309
2309
  theme={theme}
2310
2310
  modelId={visibleModelId}
2311
2311
  thinkingLevel={visibleThinkingLevel}
2312
+ cwd={cwd}
2312
2313
  branch={visibleBranch}
2313
2314
  outgoingTokens={visibleUsage.outgoing}
2314
2315
  incomingTokens={visibleUsage.incoming}
@@ -17,6 +17,7 @@ export type CheckPolicyFindingCode =
17
17
  | "external-read-exfiltration"
18
18
  | "destructive-git"
19
19
  | "broad-deletion"
20
+ | "unsafe-npm-pack"
20
21
  | "suspicious-execution"
21
22
  | "shell-complexity"
22
23
  | "mutation"
@@ -209,6 +210,84 @@ const EXPLICIT_READ_COMMANDS = new Set([
209
210
  "cat", "head", "tail", "less", "more", "stat", "file", "ls", "tree", "du", "wc", "realpath", "readlink",
210
211
  ]);
211
212
  const DATA_OPERAND_COMMANDS = new Set(["printf", "echo"]);
213
+
214
+ type NpmPackCommand = {
215
+ valid: boolean;
216
+ reason?: string;
217
+ packageSpec?: string;
218
+ cache?: string;
219
+ packDestination: string;
220
+ };
221
+
222
+ function isExactRegistryPackageVersion(value: string): boolean {
223
+ const packageName = String.raw`(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)`;
224
+ const numericIdentifier = String.raw`(?:0|[1-9]\d*)`;
225
+ const prereleaseIdentifier = String.raw`(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)`;
226
+ const buildIdentifier = String.raw`[0-9a-zA-Z-]+`;
227
+ const version = String.raw`${numericIdentifier}\.${numericIdentifier}\.${numericIdentifier}(?:-${prereleaseIdentifier}(?:\.${prereleaseIdentifier})*)?(?:\+${buildIdentifier}(?:\.${buildIdentifier})*)?`;
228
+ return new RegExp(`^${packageName}@${version}$`).test(value);
229
+ }
230
+
231
+ function npmPackCommand(argv: string[]): NpmPackCommand | undefined {
232
+ if (commandName(argv[0]) !== "npm" || argv[1] !== "pack") return undefined;
233
+ const positionals: string[] = [];
234
+ let cache: string | undefined;
235
+ let packDestination = ".";
236
+ let packDestinationSet = false;
237
+ let ignoreScripts = false;
238
+ const booleanOptions = new Set(["--dry-run", "--json", "--ignore-scripts"]);
239
+ const pathOptions = new Map([["--cache", "cache"], ["--pack-destination", "packDestination"]] as const);
240
+
241
+ for (let index = 2; index < argv.length; index++) {
242
+ const value = argv[index]!;
243
+ if (booleanOptions.has(value)) {
244
+ if (value === "--ignore-scripts") ignoreScripts = true;
245
+ continue;
246
+ }
247
+ const separate = pathOptions.get(value as "--cache" | "--pack-destination");
248
+ if (separate) {
249
+ const path = argv[++index];
250
+ if (!path || path.startsWith("-")) return { valid: false, reason: `${value} requires one explicit path`, packDestination };
251
+ if (separate === "cache") {
252
+ if (cache !== undefined) return { valid: false, reason: "npm pack --cache must occur exactly once", packDestination };
253
+ cache = path;
254
+ } else {
255
+ if (packDestinationSet) return { valid: false, reason: "npm pack --pack-destination must occur at most once", packDestination };
256
+ packDestination = path;
257
+ packDestinationSet = true;
258
+ }
259
+ continue;
260
+ }
261
+ const attached = /^(--cache|--pack-destination)=(.*)$/.exec(value);
262
+ if (attached) {
263
+ if (!attached[2]) return { valid: false, reason: `${attached[1]} requires one explicit path`, packDestination };
264
+ if (attached[1] === "--cache") {
265
+ if (cache !== undefined) return { valid: false, reason: "npm pack --cache must occur exactly once", packDestination };
266
+ cache = attached[2];
267
+ } else {
268
+ if (packDestinationSet) return { valid: false, reason: "npm pack --pack-destination must occur at most once", packDestination };
269
+ packDestination = attached[2];
270
+ packDestinationSet = true;
271
+ }
272
+ continue;
273
+ }
274
+ if (value === "--") {
275
+ positionals.push(...argv.slice(index + 1));
276
+ break;
277
+ }
278
+ if (value.startsWith("-")) return { valid: false, reason: `npm pack option ${value} is not in the deterministic allowlist`, packDestination };
279
+ positionals.push(value);
280
+ }
281
+
282
+ if (!ignoreScripts) return { valid: false, reason: "npm pack must disable lifecycle scripts with --ignore-scripts", packDestination };
283
+ if (!cache) return { valid: false, reason: "npm pack must set an explicit project-local --cache path", packDestination };
284
+ if (positionals.length > 1) return { valid: false, reason: "npm pack accepts at most one deterministic package spec", cache, packDestination };
285
+ if (positionals[0] && !isExactRegistryPackageVersion(positionals[0])) {
286
+ return { valid: false, reason: "npm pack package spec must be an exact registry package version", cache, packDestination };
287
+ }
288
+ return { valid: true, packageSpec: positionals[0], cache, packDestination };
289
+ }
290
+
212
291
  function commandName(argv0: string | undefined): string {
213
292
  if (!argv0) return "";
214
293
  const basename = argv0.replaceAll("\\", "/").split("/").at(-1) ?? argv0;
@@ -352,6 +431,7 @@ function mutationIndicators(argv: string[], redirections: BashRedirection[]): st
352
431
  if (name === "git" && argv[1] && !new Set(["status", "diff", "log", "show", "rev-parse", "ls-files", "branch"]).has(argv[1])) {
353
432
  indicators.push("Git state change");
354
433
  }
434
+ if (npmPackCommand(argv)?.valid) indicators.push("package archive or cache output");
355
435
  return [...new Set(indicators)];
356
436
  }
357
437
 
@@ -742,6 +822,12 @@ function classifyStageAccesses(stage: BashStage): ClassifiedAccess[] {
742
822
  }
743
823
 
744
824
  if (DATA_OPERAND_COMMANDS.has(name)) return accesses;
825
+ const npmPack = npmPackCommand(argv);
826
+ if (npmPack?.valid) {
827
+ accesses.push({ path: npmPack.cache!, mode: "write", source: "operand" });
828
+ accesses.push({ path: npmPack.packDestination, mode: "write", source: "operand" });
829
+ return accesses;
830
+ }
745
831
  if (["cd", "chdir", "set-location"].includes(name)) {
746
832
  return [...accesses, ...positionalOperands(argv).map((path) => ({ path, mode: "location" as const, source: "operand" as const }))];
747
833
  }
@@ -930,6 +1016,34 @@ function inspectHardBlocks(
930
1016
  const argv = effectiveArgv(stage.argv);
931
1017
  const name = commandName(argv[0]);
932
1018
  const lowerArgs = argv.map((arg) => arg.toLowerCase());
1019
+ const npmPack = npmPackCommand(argv);
1020
+ if (npmPack) {
1021
+ const direct = commandName(stage.argv[0]) === "npm"
1022
+ && analysis.stages.length === 1
1023
+ && analysis.operators.length === 0
1024
+ && stage.substitutions.length === 0
1025
+ && stage.redirections.length === 0
1026
+ && Object.keys(stage.envAssignments).length === 0;
1027
+ if (!direct || !npmPack.valid) {
1028
+ addFinding(findings, {
1029
+ code: "unsafe-npm-pack",
1030
+ severity: "hard-block",
1031
+ message: !direct ? "npm pack must be one direct command without shell composition" : npmPack.reason!,
1032
+ stage: stage.index,
1033
+ });
1034
+ }
1035
+ }
1036
+ const globalPackageWrite = (name === "npm" || name === "bun")
1037
+ && lowerArgs.some((arg) => new Set(["install", "add", "i", "update", "upgrade", "remove", "uninstall", "link"]).has(arg))
1038
+ && lowerArgs.some((arg) => arg === "-g" || arg === "--global");
1039
+ if (globalPackageWrite) {
1040
+ addFinding(findings, {
1041
+ code: "outside-project",
1042
+ severity: "hard-block",
1043
+ message: "global package installation writes outside the project and approved roots",
1044
+ stage: stage.index,
1045
+ });
1046
+ }
933
1047
  if (PRIVILEGE_COMMANDS.has(name)
934
1048
  || ((name === "powershell" || name === "start-process") && lowerArgs.includes("runas"))) {
935
1049
  addFinding(findings, { code: "privilege-escalation", severity: "hard-block", message: `${name} can escalate privileges`, stage: stage.index });
@@ -1191,6 +1305,9 @@ function stageNetworkCommand(stage: BashStage): string | undefined {
1191
1305
  if (NETWORK_COMMANDS.has(name)) return name;
1192
1306
  const subcommand = (argv[1] ?? "").toLowerCase();
1193
1307
  if (name === "git" && new Set(["clone", "fetch", "pull", "push", "ls-remote"]).has(subcommand)) return `git ${subcommand}`;
1308
+ if (name === "npm" && subcommand === "pack") {
1309
+ return npmPackCommand(argv)?.packageSpec ? "npm pack" : undefined;
1310
+ }
1194
1311
  if (["npm", "pnpm", "yarn"].includes(name)
1195
1312
  && new Set(["add", "audit", "install", "publish", "search", "update", "upgrade", "view", "info"]).has(subcommand)) {
1196
1313
  return `${name} ${subcommand}`;
@@ -15,6 +15,7 @@ export type StatusProps = {
15
15
  theme: Theme;
16
16
  modelId: string;
17
17
  thinkingLevel: string;
18
+ cwd: string;
18
19
  branch: string | null;
19
20
  outgoingTokens: number;
20
21
  incomingTokens: number;
@@ -65,6 +66,7 @@ type StatusBarLayoutInput = Pick<
65
66
  StatusProps,
66
67
  | "modelId"
67
68
  | "thinkingLevel"
69
+ | "cwd"
68
70
  | "branch"
69
71
  | "outgoingTokens"
70
72
  | "incomingTokens"
@@ -183,6 +185,10 @@ export function statusBarLayout(input: StatusBarLayoutInput): StatusBarLayout {
183
185
  layout.trailingSpace = false;
184
186
  measureLayout(input, layout);
185
187
  }
188
+ if (layout.totalWidth > input.width) {
189
+ layout.metadata = layout.metadata.filter((item) => item.key !== "cwd");
190
+ measureLayout(input, layout);
191
+ }
186
192
  if (layout.totalWidth > input.width) {
187
193
  layout.showIdleAgents = false;
188
194
  measureLayout(input, layout);
@@ -2,6 +2,7 @@ import { fg, type TextChunk } from "@opentui/core";
2
2
  import type { Theme } from "./theme";
3
3
 
4
4
  export type StatusMetadataValues = {
5
+ cwd?: string;
5
6
  branch: string | null;
6
7
  outgoingTokens: number;
7
8
  incomingTokens: number;
@@ -11,9 +12,9 @@ export type StatusMetadataValues = {
11
12
  };
12
13
 
13
14
  export type StatusMetadataItem = {
14
- key: "branch" | "outgoing" | "incoming" | "cacheRead" | "cost" | "context";
15
+ key: "cwd" | "branch" | "outgoing" | "incoming" | "cacheRead" | "cost" | "context";
15
16
  text: string;
16
- tone: "branch" | "dim" | "warn";
17
+ tone: "cwd" | "branch" | "dim" | "warn";
17
18
  priority: number;
18
19
  };
19
20
 
@@ -28,8 +29,20 @@ export const formatCost = (value: number): string =>
28
29
 
29
30
  export const statusTextWidth = (text: string): number => Bun.stringWidth(text);
30
31
 
32
+ /** Show the launch directory without the long parent path. */
33
+ export function formatWorkingDirectory(cwd: string): string {
34
+ const withoutTrailingSeparators = cwd.replace(/[\\/]+$/, "");
35
+ if (!withoutTrailingSeparators) return "cwd /";
36
+ if (/^[A-Za-z]:$/.test(withoutTrailingSeparators)) return `cwd ${withoutTrailingSeparators}\\`;
37
+ const name = withoutTrailingSeparators.split(/[\\/]/).at(-1) || withoutTrailingSeparators;
38
+ return `cwd ${name}`;
39
+ }
40
+
31
41
  export function statusMetadataItems(values: StatusMetadataValues): StatusMetadataItem[] {
32
42
  const items: StatusMetadataItem[] = [];
43
+ if (values.cwd) {
44
+ items.push({ key: "cwd", text: formatWorkingDirectory(values.cwd), tone: "cwd", priority: 85 });
45
+ }
33
46
  if (values.branch) {
34
47
  items.push({ key: "branch", text: values.branch, tone: "branch", priority: 90 });
35
48
  }
@@ -101,8 +114,10 @@ export function statusMetadataChunks(
101
114
  const chunks: TextChunk[] = [];
102
115
  for (const item of items) {
103
116
  if (chunks.length) chunks.push(fg(theme.dim)(" · "));
104
- const color = item.tone === "branch"
105
- ? theme.toolArg
117
+ const color = item.tone === "cwd"
118
+ ? theme.statusCwd
119
+ : item.tone === "branch"
120
+ ? theme.toolArg
106
121
  : item.tone === "warn"
107
122
  ? theme.warn
108
123
  : theme.dim;
package/src/theme.ts CHANGED
@@ -9,6 +9,8 @@ export type Theme = {
9
9
  fg: string;
10
10
  dim: string;
11
11
  accent: string;
12
+ /** Foreground for the current working directory in the status bar. */
13
+ statusCwd: string;
12
14
  border: string;
13
15
  /** Foreground and background of a user turn's full-width bar. */
14
16
  user: string;
@@ -47,6 +49,7 @@ const tokyonight: Theme = {
47
49
  fg: "#c0caf5",
48
50
  dim: "#565f89",
49
51
  accent: "#7aa2f7",
52
+ statusCwd: "#2ac3de",
50
53
  border: "#292e42",
51
54
  user: "#c0caf5",
52
55
  userBg: "#283457",
@@ -79,6 +82,7 @@ const gruvbox: Theme = {
79
82
  fg: "#ebdbb2",
80
83
  dim: "#928374",
81
84
  accent: "#83a598",
85
+ statusCwd: "#8ec07c",
82
86
  border: "#3c3836",
83
87
  user: "#ebdbb2",
84
88
  userBg: "#3c3836",
@@ -111,6 +115,7 @@ const catppuccin: Theme = {
111
115
  fg: "#cdd6f4",
112
116
  dim: "#6c7086",
113
117
  accent: "#89b4fa",
118
+ statusCwd: "#94e2d5",
114
119
  border: "#313244",
115
120
  user: "#cdd6f4",
116
121
  userBg: "#313244",
@@ -143,6 +148,7 @@ const nord: Theme = {
143
148
  fg: "#d8dee9",
144
149
  dim: "#7b88a1",
145
150
  accent: "#88c0d0",
151
+ statusCwd: "#8fbcbb",
146
152
  border: "#3b4252",
147
153
  user: "#eceff4",
148
154
  userBg: "#434c5e",
@@ -175,6 +181,7 @@ const dracula: Theme = {
175
181
  fg: "#f8f8f2",
176
182
  dim: "#6272a4",
177
183
  accent: "#bd93f9",
184
+ statusCwd: "#8be9fd",
178
185
  border: "#44475a",
179
186
  user: "#f8f8f2",
180
187
  userBg: "#44475a",
@@ -207,6 +214,7 @@ const rosepine: Theme = {
207
214
  fg: "#e0def4",
208
215
  dim: "#6e6a86",
209
216
  accent: "#c4a7e7",
217
+ statusCwd: "#9ccfd8",
210
218
  border: "#26233a",
211
219
  user: "#e0def4",
212
220
  userBg: "#26233a",
@@ -239,6 +247,7 @@ const solarized: Theme = {
239
247
  fg: "#839496",
240
248
  dim: "#586e75",
241
249
  accent: "#268bd2",
250
+ statusCwd: "#2aa198",
242
251
  border: "#073642",
243
252
  user: "#93a1a1",
244
253
  userBg: "#073642",
@@ -271,6 +280,7 @@ const kanagawa: Theme = {
271
280
  fg: "#dcd7ba",
272
281
  dim: "#727169",
273
282
  accent: "#7e9cd8",
283
+ statusCwd: "#7fb4ca",
274
284
  border: "#2a2a37",
275
285
  user: "#dcd7ba",
276
286
  userBg: "#2d4f67",
@@ -303,6 +313,7 @@ const githubLight: Theme = {
303
313
  fg: "#24292f",
304
314
  dim: "#6e7781",
305
315
  accent: "#0969da",
316
+ statusCwd: "#0a3069",
306
317
  border: "#d0d7de",
307
318
  user: "#24292f",
308
319
  userBg: "#ddf4ff",