pum-agent 0.2.0-beta.4 → 0.2.2-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 +43 -8
- package/package.json +3 -2
- package/src/app.tsx +49 -8
- package/src/check-mode.ts +4 -1
- package/src/check-mutation.ts +28 -16
- package/src/check-paths.ts +16 -12
- package/src/check-policy.ts +423 -28
- package/src/main.tsx +16 -0
- package/src/platform.ts +39 -0
- package/src/sandbox/index.ts +207 -0
- package/src/sandbox/linux.ts +362 -0
- package/src/sandbox/types.ts +62 -0
- package/src/sandbox/windows.ts +327 -0
- package/src/sandbox-policy.ts +204 -0
- package/src/settings-popup.tsx +2 -0
- package/src/settings.ts +10 -0
- package/src/terminal-title.ts +41 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ pum
|
|
|
23
23
|
PUM opens the login panel automatically on the first start.
|
|
24
24
|
|
|
25
25
|
> [!WARNING]
|
|
26
|
-
> PUM can read, write, and delete files.
|
|
26
|
+
> PUM can read, write, and delete files. Check mode adds deterministic policy checks and can enforce native Bash isolation on supported Linux and Windows hosts. Other tools, extensions, and external triggers still run in the PUM process boundary described below. Review the safeguards and prerequisites before using untrusted workspaces.
|
|
27
27
|
|
|
28
28
|
## See PUM in action
|
|
29
29
|
|
|
@@ -46,7 +46,7 @@ The following screens are real OpenTUI renders captured through `tmux`. A local
|
|
|
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.
|
|
47
47
|
- **External triggers:** Supervise background commands such as `gh run watch` and automatically wake the exact target agent when they exit.
|
|
48
48
|
- **Provider choice:** Search the providers exposed by pi, or add an OpenAI-compatible custom endpoint.
|
|
49
|
-
- **Optional safeguards:** Use strict, balanced, or ask Check mode
|
|
49
|
+
- **Optional safeguards:** Use strict, balanced, or ask Check mode, plus native Bash sandboxing through Bubblewrap or Windows CreateProcessInSandbox when available.
|
|
50
50
|
- **Terminal-first appearance:** Nine themes, semantic color overrides, Unicode glyphs, and optional animation.
|
|
51
51
|
|
|
52
52
|
PUM uses [pi](https://github.com/earendil-works/pi) for the agent loop and [OpenTUI](https://github.com/anomalyco/opentui) for rendering.
|
|
@@ -60,7 +60,22 @@ PUM uses [pi](https://github.com/earendil-works/pi) for the agent loop and [Open
|
|
|
60
60
|
|
|
61
61
|
Linux and macOS are the primary environments. Windows CI checks the code and Windows path behavior. Native Windows TUI operation remains provisional because it has not been fully validated in a Windows terminal.
|
|
62
62
|
|
|
63
|
-
On
|
|
63
|
+
On Linux, native Bash sandboxing requires Bubblewrap (`bwrap`) and working unprivileged user namespaces. PUM probes a minimal sandbox launch; finding the executable alone is not sufficient. On Arch Linux, install the prerequisite separately with `sudo pacman -S --needed bubblewrap`.
|
|
64
|
+
|
|
65
|
+
On Windows, install Git for Windows. Ensure that `bash.exe` is in `PATH` or remains in its standard location. Use Windows Terminal with PowerShell. Do not use PowerShell ISE. Native Bash sandboxing uses the optional alpha `@microsoft/mxc-sdk` package and requires its `base-container` CreateProcessInSandbox tier. PUM deliberately rejects the SDK's AppContainer+DACL fallback because it can modify host ACLs.
|
|
66
|
+
|
|
67
|
+
### Terminal title
|
|
68
|
+
|
|
69
|
+
PUM sets a compact terminal title such as `Pum · working · 2 subagents`. The title reports overall activity and counts only starting or running subagents. PUM clears the title during graceful shutdown.
|
|
70
|
+
|
|
71
|
+
Windows Terminal and common Linux terminal emulators accept the title through OpenTUI. Inside `tmux`, PUM sets the active pane title. To copy that pane title to the outer terminal title, add this configuration:
|
|
72
|
+
|
|
73
|
+
```tmux
|
|
74
|
+
set -g set-titles on
|
|
75
|
+
set -g set-titles-string '#T'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Keep `allow-set-title` enabled so applications can update the pane title. A `tmux` configuration can replace or suppress application titles. PUM cannot override that server policy.
|
|
64
79
|
|
|
65
80
|
## Install and start
|
|
66
81
|
|
|
@@ -129,7 +144,7 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
|
|
|
129
144
|
| `Ctrl+C` | Clear the selected non-empty draft; on an empty draft, press twice to quit |
|
|
130
145
|
| `?` | Show all controls when the prompt is empty |
|
|
131
146
|
|
|
132
|
-
Useful commands include `/login`, `/history`, `/triggers`, `/clear`, `/compress`, and `/worktree`.
|
|
147
|
+
Useful commands include `/login`, `/history`, `/triggers`, `/check-path`, `/clear`, `/compress`, and `/worktree`.
|
|
133
148
|
|
|
134
149
|
### Copy transcript text
|
|
135
150
|
|
|
@@ -205,16 +220,36 @@ Trigger events target one exact main or retained child session. A missing sessio
|
|
|
205
220
|
Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
|
|
206
221
|
|
|
207
222
|
- **Strict:** Run deterministic hard rules, then require a clear verifier approval.
|
|
208
|
-
- **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
|
|
223
|
+
- **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`.
|
|
209
224
|
- **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.
|
|
210
225
|
|
|
211
|
-
Every active profile hard-blocks
|
|
226
|
+
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.
|
|
227
|
+
|
|
228
|
+
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.
|
|
212
229
|
|
|
213
230
|
For `edit` and `apply_patch`, PUM validates the complete proposed change before any mutation. Review data includes the unified diff, changed paths, line counts, sensitivity flags, project containment, and full-content SHA-256. Invalid, stale, malformed, escaping, or incompletely analyzed input blocks the call. Patch length alone does not block a valid Balanced call.
|
|
214
231
|
|
|
215
232
|
Ask mode can allow an exact call once, for the current session, or for the current project. Approvals match the authoritative main or child identity, tool, verifier model, project, and canonical complete input. Chat text is not approval. Use **Clear approvals** in Settings to remove project approvals.
|
|
216
233
|
|
|
217
|
-
The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict blocks malformed replies, errors, aborts, and timeouts. Balanced allows a fully validated call after an unclear, unavailable, failed, or timed-out review. Balanced still blocks explicit verifier `UNSAFE`, aborts, deterministic suspicious findings, malformed structures, and incomplete analysis. Ask requires the popup after hard rules for verifier `SAFE`, unclear, error, and unavailable results. Check mode is off by default
|
|
234
|
+
The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict blocks malformed replies, errors, aborts, and timeouts. Balanced allows a fully validated call after an unclear, unavailable, failed, or timed-out review. Balanced still blocks explicit verifier `UNSAFE`, aborts, deterministic suspicious findings, malformed structures, and incomplete analysis. Ask requires the popup after hard rules for verifier `SAFE`, unclear, error, and unavailable results. Check mode is off by default.
|
|
235
|
+
|
|
236
|
+
#### Native Bash sandbox
|
|
237
|
+
|
|
238
|
+
The **Sandbox** setting has three modes:
|
|
239
|
+
|
|
240
|
+
- **Auto:** Enforce the platform sandbox for Bash when available. If probing fails, retain deterministic Check mode and show one process-local warning that is not written to session context.
|
|
241
|
+
- **Require:** Block checked Bash calls unless native enforcement is available.
|
|
242
|
+
- **Off:** Do not sandbox Bash. Check mode policy and approval behavior remain unchanged.
|
|
243
|
+
|
|
244
|
+
Check mode **Off** always uses pi's normal unsandboxed Bash backend. For an active Check mode, PUM recomputes the sandbox policy from the exact approved command, authoritative working directory, configured additional roots, and deterministic access analysis. Model input cannot supply policy fields.
|
|
245
|
+
|
|
246
|
+
The project and configured additional roots are writable. Explicit Balanced external reads are mounted read-only. PUM configuration and common credential paths are denied, and credential-shaped or process-injection environment variables are removed. A private temporary directory is supplied for the command. Safe pi metadata such as `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` remains available; session paths and identifiers are withheld.
|
|
247
|
+
|
|
248
|
+
Network access is denied unless deterministic analysis recognizes an approved network operation. Bubblewrap's host-network mode is all-or-nothing and is **not domain-filtered**. Windows similarly grants or withholds the SDK's broad network capabilities; it does not provide hostname allowlists.
|
|
249
|
+
|
|
250
|
+
The override uses pi's `createBashTool` implementation and custom Bash operations, preserving streaming, truncation, full-output files, rendering, timeout messages, abort handling, shell configuration, and child-tree cleanup. Only Bash commands are routed through this backend. PUM does not sandbox the TUI/model process itself.
|
|
251
|
+
|
|
252
|
+
External triggers preserve direct executable/argument boundaries and continue to use deterministic Check mode, but they are not routed through the native sandbox in this release. The trigger manager's synchronous spawn boundary does not carry the exact approved policy object into execution; silently recomputing a second process policy there would weaken approval identity. Trigger output, environment, limits, and process supervision remain unchanged.
|
|
218
253
|
|
|
219
254
|
Verifier prompts stay bounded. For an oversized Balanced review, PUM sends complete validation metadata, counts, findings, and SHA-256 digests. PUM does not send a raw prefix or suffix as if it were complete. Strict and Ask keep their fail-closed oversized-input behavior.
|
|
220
255
|
|
|
@@ -252,7 +287,7 @@ Set `PUM_DIR` to override the complete PUM data directory.
|
|
|
252
287
|
| `auth.json` | Provider credentials and custom-provider keys |
|
|
253
288
|
| `models.json` | Custom endpoints and model metadata; submitted keys are not stored here |
|
|
254
289
|
| `settings.json` | Model and thinking level managed by pi |
|
|
255
|
-
| `pum.json` | Theme, animation, search, writing, explanation, and
|
|
290
|
+
| `pum.json` | Theme, animation, search, writing, explanation, Check mode, sandbox, and subagent settings |
|
|
256
291
|
| `theme.json` | Optional semantic color overrides |
|
|
257
292
|
| `history.json` | Prompt history by working directory |
|
|
258
293
|
| `prompt-stash.json` | Stashed prompts by working directory |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pum-agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2-beta.1",
|
|
4
4
|
"description": "A compact terminal coding agent powered by pi and OpenTUI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"ws": "^8.21.3"
|
|
61
61
|
},
|
|
62
62
|
"optionalDependencies": {
|
|
63
|
-
"@mariozechner/clipboard": "0.3.9"
|
|
63
|
+
"@mariozechner/clipboard": "0.3.9",
|
|
64
|
+
"@microsoft/mxc-sdk": "^0.7.0"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
67
|
"@types/bun": "^1.3.14",
|
package/src/app.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
checkPathsForProject,
|
|
23
23
|
MAX_ACTIVE_SUBAGENTS,
|
|
24
24
|
MIN_ACTIVE_SUBAGENTS,
|
|
25
|
+
SANDBOX_MODES,
|
|
25
26
|
saveSettings,
|
|
26
27
|
WORKING_RULE_ANIMATION_MODES,
|
|
27
28
|
type PumSettings,
|
|
@@ -81,7 +82,7 @@ import {
|
|
|
81
82
|
removePendingImage,
|
|
82
83
|
type PendingImage,
|
|
83
84
|
} from "./image-paste";
|
|
84
|
-
import type
|
|
85
|
+
import { countActiveSubagents, type SubagentManager } from "./subagents/manager";
|
|
85
86
|
import type { SpawnPreviewManager } from "./subagents/spawn-preview";
|
|
86
87
|
import { SpawnPreviewPopup } from "./subagents/spawn-preview-popup";
|
|
87
88
|
import { recallNewestQueuedUserMessage } from "./queue-recall";
|
|
@@ -119,6 +120,7 @@ import {
|
|
|
119
120
|
type TriggerAction,
|
|
120
121
|
type TriggerManagerLike,
|
|
121
122
|
} from "./triggers/popup";
|
|
123
|
+
import type { TerminalTitleController } from "./terminal-title";
|
|
122
124
|
|
|
123
125
|
type Stream = { kind: "assistant" | "thinking"; text: string } | null;
|
|
124
126
|
type Transcript = { lines: Line[]; stream: Stream; pending: PendingLine[] };
|
|
@@ -344,6 +346,10 @@ export function App({
|
|
|
344
346
|
checkApprovalStore,
|
|
345
347
|
triggerManager,
|
|
346
348
|
messageCacheController,
|
|
349
|
+
terminalTitle,
|
|
350
|
+
startupWarnings = [],
|
|
351
|
+
onSandboxModeChange,
|
|
352
|
+
sandboxWarningSource,
|
|
347
353
|
}: {
|
|
348
354
|
session: AgentSession;
|
|
349
355
|
modelRuntime: ModelRuntime;
|
|
@@ -365,16 +371,24 @@ export function App({
|
|
|
365
371
|
checkApprovalStore?: CheckApprovalStore;
|
|
366
372
|
triggerManager?: TriggerManagerLike;
|
|
367
373
|
messageCacheController?: MessageCacheController;
|
|
374
|
+
terminalTitle?: TerminalTitleController;
|
|
375
|
+
/** Visible process-local warnings. These lines never enter pi session context. */
|
|
376
|
+
startupWarnings?: readonly string[];
|
|
377
|
+
onSandboxModeChange?: (mode: NonNullable<PumSettings["sandboxMode"]>) => void;
|
|
378
|
+
sandboxWarningSource?: { subscribeWarnings(listener: (warning: string) => void): () => void };
|
|
368
379
|
}) {
|
|
369
380
|
const cwd = process.cwd();
|
|
370
381
|
const [session, setSession] = useState(initialSession);
|
|
371
382
|
const [tx, setTx] = useState<Transcript>(() => ({
|
|
372
383
|
// A resumed session already holds messages; show them instead of a blank pane.
|
|
373
|
-
lines:
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
384
|
+
lines: [
|
|
385
|
+
...replayEntries(
|
|
386
|
+
initialSession.sessionManager.buildContextEntries(),
|
|
387
|
+
cwd,
|
|
388
|
+
initial.showThinking,
|
|
389
|
+
),
|
|
390
|
+
...startupWarnings.map((text): Line => ({ kind: "text", role: "system", text })),
|
|
391
|
+
],
|
|
378
392
|
stream: null,
|
|
379
393
|
pending: [],
|
|
380
394
|
}));
|
|
@@ -436,6 +450,7 @@ export function App({
|
|
|
436
450
|
const syntaxStyle = useMemo(() => buildSyntaxStyle(theme), [theme]);
|
|
437
451
|
const animations = settings.animations && supportsTrueColor();
|
|
438
452
|
const agents = subagentManager.getAgents();
|
|
453
|
+
const activeSubagentCount = countActiveSubagents(agents);
|
|
439
454
|
const activeAgent = activeAgentId
|
|
440
455
|
? agents.find((agent) => agent.id === activeAgentId)
|
|
441
456
|
: undefined;
|
|
@@ -553,6 +568,7 @@ export function App({
|
|
|
553
568
|
};
|
|
554
569
|
// The event subscription is set up once, so it reads the toggle via a ref.
|
|
555
570
|
const showThinkingRef = useRef(initial.showThinking);
|
|
571
|
+
const startupWarningsRef = useRef([...startupWarnings]);
|
|
556
572
|
const sessionRef = useRef(session);
|
|
557
573
|
sessionRef.current = session;
|
|
558
574
|
const loginControllerRef = useRef<LoginController | null>(null);
|
|
@@ -794,6 +810,10 @@ export function App({
|
|
|
794
810
|
return pending ? resolvePendingDelivery(value, pending.id) : value;
|
|
795
811
|
});
|
|
796
812
|
|
|
813
|
+
useEffect(() => sandboxWarningSource?.subscribeWarnings((warning) => {
|
|
814
|
+
append({ kind: "text", role: "system", text: warning });
|
|
815
|
+
}), [sandboxWarningSource]);
|
|
816
|
+
|
|
797
817
|
useEffect(() => checkApprovalCoordinator?.subscribe((request) => {
|
|
798
818
|
setCheckApproval(request);
|
|
799
819
|
setCheckApprovalDecision("allowOnce");
|
|
@@ -818,6 +838,13 @@ export function App({
|
|
|
818
838
|
[subagentManager],
|
|
819
839
|
);
|
|
820
840
|
|
|
841
|
+
useEffect(() => {
|
|
842
|
+
terminalTitle?.update({
|
|
843
|
+
working: busy || activeSubagentCount > 0,
|
|
844
|
+
activeSubagentCount,
|
|
845
|
+
});
|
|
846
|
+
}, [terminalTitle, busy, activeSubagentCount]);
|
|
847
|
+
|
|
821
848
|
useEffect(() => spawnPreviewManager?.subscribe(() => {
|
|
822
849
|
setSpawnPreviewRevision((revision) => revision + 1);
|
|
823
850
|
}), [spawnPreviewManager]);
|
|
@@ -865,8 +892,13 @@ export function App({
|
|
|
865
892
|
.catch((error) => append({ kind: "text", role: "error", text: String(error) }));
|
|
866
893
|
setThinkingLevel(session.agent.state.thinkingLevel as ThinkingLevel);
|
|
867
894
|
setModelId(session.agent.state.model.id);
|
|
895
|
+
const visibleStartupWarnings = startupWarningsRef.current;
|
|
896
|
+
startupWarningsRef.current = [];
|
|
868
897
|
setTx({
|
|
869
|
-
lines:
|
|
898
|
+
lines: [
|
|
899
|
+
...replayEntries(session.sessionManager.buildContextEntries(), cwd, showThinkingRef.current),
|
|
900
|
+
...visibleStartupWarnings.map((text): Line => ({ kind: "text", role: "system", text })),
|
|
901
|
+
],
|
|
870
902
|
stream: null,
|
|
871
903
|
pending: [],
|
|
872
904
|
});
|
|
@@ -1029,6 +1061,7 @@ export function App({
|
|
|
1029
1061
|
if (patch.explanationStrength !== undefined) {
|
|
1030
1062
|
setExplanationStrength(patch.explanationStrength);
|
|
1031
1063
|
}
|
|
1064
|
+
if (patch.sandboxMode !== undefined) onSandboxModeChange?.(patch.sandboxMode);
|
|
1032
1065
|
if (patch.checkMode !== undefined || patch.checkModel !== undefined || patch.checkPaths !== undefined) {
|
|
1033
1066
|
setCheckModeConfig({
|
|
1034
1067
|
profile: next.checkMode,
|
|
@@ -1519,6 +1552,12 @@ export function App({
|
|
|
1519
1552
|
update({ checkMode: CHECK_MODE_PROFILES[(index + step + CHECK_MODE_PROFILES.length) % CHECK_MODE_PROFILES.length]! });
|
|
1520
1553
|
};
|
|
1521
1554
|
|
|
1555
|
+
const stepSandboxMode = (step: number) => {
|
|
1556
|
+
const current = settings.sandboxMode ?? "auto";
|
|
1557
|
+
const index = SANDBOX_MODES.indexOf(current);
|
|
1558
|
+
update({ sandboxMode: SANDBOX_MODES[(index + step + SANDBOX_MODES.length) % SANDBOX_MODES.length]! });
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1522
1561
|
const rowActions: Record<SettingRowId, { step?: (n: number) => void; enter?: () => void }> = {
|
|
1523
1562
|
theme: { step: stepTheme },
|
|
1524
1563
|
providers: { enter: openLogin },
|
|
@@ -1528,6 +1567,7 @@ export function App({
|
|
|
1528
1567
|
writingStyle: { step: stepWritingStyle },
|
|
1529
1568
|
explanationStrength: { step: stepExplanationStrength },
|
|
1530
1569
|
checkMode: { step: stepCheckMode },
|
|
1570
|
+
sandboxMode: { step: stepSandboxMode },
|
|
1531
1571
|
checkModel: { enter: () => {
|
|
1532
1572
|
setModelQuery("");
|
|
1533
1573
|
setModelSearchFocused(false);
|
|
@@ -1575,6 +1615,7 @@ export function App({
|
|
|
1575
1615
|
writingStyle: `‹ ${settings.writingStyle} ›`,
|
|
1576
1616
|
explanationStrength: `‹ ${settings.explanationStrength} ›`,
|
|
1577
1617
|
checkMode: `‹ ${settings.checkMode} ›`,
|
|
1618
|
+
sandboxMode: `‹ ${settings.sandboxMode ?? "auto"} ›`,
|
|
1578
1619
|
checkModel: `${settings.checkModel} ›`,
|
|
1579
1620
|
checkPaths: `${checkPathsForProject(settings, cwd).length} additional · /check-path ›`,
|
|
1580
1621
|
clearCheckApprovals: "clear ›",
|
|
@@ -2236,7 +2277,7 @@ export function App({
|
|
|
2236
2277
|
busy={visibleBusy}
|
|
2237
2278
|
elapsedSec={visibleElapsedSec}
|
|
2238
2279
|
agentCount={agents.length}
|
|
2239
|
-
runningAgentCount={
|
|
2280
|
+
runningAgentCount={activeSubagentCount}
|
|
2240
2281
|
maxActiveAgentCount={settings.maxActiveSubagents}
|
|
2241
2282
|
activeAgentName={activeAgent?.name}
|
|
2242
2283
|
/>
|
package/src/check-mode.ts
CHANGED
|
@@ -262,12 +262,13 @@ export async function prepareCheck(
|
|
|
262
262
|
cwd: input.cwd,
|
|
263
263
|
projectCwd: cwd,
|
|
264
264
|
allowedPaths: additionalPaths,
|
|
265
|
+
protectedPaths: [AGENT_DIR],
|
|
265
266
|
profile,
|
|
266
267
|
});
|
|
267
268
|
} else {
|
|
268
269
|
const command = input && typeof input === "object" ? (input as { command?: unknown }).command : undefined;
|
|
269
270
|
if (typeof command !== "string") return { block: "Bash safety check requires a complete command string or process proposal" };
|
|
270
|
-
policy = analyzeCheckPolicy({ command, cwd, profile, allowedPaths: additionalPaths });
|
|
271
|
+
policy = analyzeCheckPolicy({ command, cwd, profile, allowedPaths: additionalPaths, protectedPaths: [AGENT_DIR] });
|
|
271
272
|
}
|
|
272
273
|
bash = policy.analysis;
|
|
273
274
|
if (!bash.complete || bash.truncated || !bash.syntaxBalanced) {
|
|
@@ -316,6 +317,7 @@ export async function prepareCheck(
|
|
|
316
317
|
decision: policy.decision,
|
|
317
318
|
reason: policy.reason,
|
|
318
319
|
findings: policy.findings,
|
|
320
|
+
accesses: policy.accesses,
|
|
319
321
|
} : undefined,
|
|
320
322
|
shell: processProposal ? undefined : bash,
|
|
321
323
|
process: processProposal ? {
|
|
@@ -379,6 +381,7 @@ export async function prepareCheck(
|
|
|
379
381
|
substitutionCount: bash.substitutions.length,
|
|
380
382
|
mutationIntent: bash.mutationIntent,
|
|
381
383
|
errors: bash.errors,
|
|
384
|
+
accesses: policy?.accesses,
|
|
382
385
|
} : undefined,
|
|
383
386
|
process: processProposal ? {
|
|
384
387
|
source: processProposal.source,
|
package/src/check-mutation.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
4
|
-
import { basename, dirname,
|
|
4
|
+
import { basename, dirname, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { previewApplyPatch } from "./apply-patch";
|
|
6
6
|
import type { CheckedToolName } from "./check-approvals";
|
|
7
|
+
import {
|
|
8
|
+
canonicalPathIdentityAllowMissing,
|
|
9
|
+
isPathInsideOrSame,
|
|
10
|
+
pathIdentity,
|
|
11
|
+
pathsHaveSameIdentity,
|
|
12
|
+
} from "./platform";
|
|
7
13
|
|
|
8
14
|
export type MutationSensitivity = {
|
|
9
15
|
executable: boolean;
|
|
@@ -66,11 +72,6 @@ function windowsAbsolute(path: string): boolean {
|
|
|
66
72
|
return /^[A-Za-z]:[\\/]/.test(path) || /^\\\\/.test(path) || /^\/\//.test(path);
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
function insideRoot(root: string, path: string): boolean {
|
|
70
|
-
const rel = relative(root, path);
|
|
71
|
-
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
75
|
async function validateEditPath(
|
|
75
76
|
cwd: string,
|
|
76
77
|
inputPath: string,
|
|
@@ -83,23 +84,34 @@ async function validateEditPath(
|
|
|
83
84
|
const projectRoot = await realpath(cwd);
|
|
84
85
|
const roots = await Promise.all([projectRoot, ...allowedPaths].map((path) => realpath(path)));
|
|
85
86
|
const absolute = resolve(projectRoot, inputPath);
|
|
86
|
-
const
|
|
87
|
-
|
|
87
|
+
const sortedRoots = roots.sort((first, second) => second.length - first.length);
|
|
88
|
+
let root = sortedRoots.find((candidate) => isPathInsideOrSame(candidate, absolute));
|
|
89
|
+
if (!root) {
|
|
90
|
+
let targetIdentity: string;
|
|
91
|
+
try {
|
|
92
|
+
targetIdentity = await canonicalPathIdentityAllowMissing(absolute);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
|
|
95
|
+
}
|
|
96
|
+
root = sortedRoots.find((candidate) => isPathInsideOrSame(pathIdentity(candidate), targetIdentity));
|
|
97
|
+
}
|
|
88
98
|
if (!root) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
|
|
99
|
+
const canonical = await realpath(absolute);
|
|
89
100
|
|
|
90
|
-
let component =
|
|
91
|
-
|
|
92
|
-
for (const part of rel.split(sep).filter(Boolean)) {
|
|
93
|
-
component = resolve(component, part);
|
|
101
|
+
let component = absolute;
|
|
102
|
+
while (true) {
|
|
94
103
|
const metadata = await lstat(component);
|
|
95
104
|
if (metadata.isSymbolicLink()) throw new Error(`Edit path contains an escaping link or junction: ${inputPath}`);
|
|
105
|
+
if (await pathsHaveSameIdentity(component, root)) break;
|
|
106
|
+
const parent = dirname(component);
|
|
107
|
+
if (parent === component) throw new Error(`Edit path is outside the allowed Check mode paths: ${inputPath}`);
|
|
108
|
+
component = parent;
|
|
96
109
|
}
|
|
97
|
-
|
|
98
|
-
if (!insideRoot(root, canonical)) throw new Error(`Edit path resolves outside the project: ${inputPath}`);
|
|
110
|
+
if (!isPathInsideOrSame(root, canonical)) throw new Error(`Edit path resolves outside the project: ${inputPath}`);
|
|
99
111
|
const metadata = await lstat(absolute);
|
|
100
112
|
if (!metadata.isFile()) throw new Error(`Edit path is not a file: ${inputPath}`);
|
|
101
|
-
const display =
|
|
102
|
-
? relative(projectRoot,
|
|
113
|
+
const display = isPathInsideOrSame(projectRoot, canonical)
|
|
114
|
+
? relative(projectRoot, canonical).split(sep).join("/")
|
|
103
115
|
: absolute;
|
|
104
116
|
return { root, absolute, display, mode: metadata.mode };
|
|
105
117
|
}
|
package/src/check-paths.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { lstat, realpath } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname,
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { AGENT_DIR } from "./config";
|
|
5
5
|
import { isCredentialSensitivePath } from "./check-policy";
|
|
6
|
+
import {
|
|
7
|
+
canonicalPathIdentityAllowMissing,
|
|
8
|
+
isPathInsideOrSame,
|
|
9
|
+
pathIdentity,
|
|
10
|
+
} from "./platform";
|
|
6
11
|
import {
|
|
7
12
|
checkPathsForProject,
|
|
8
13
|
MAX_CHECK_PATHS_PER_PROJECT,
|
|
@@ -39,11 +44,6 @@ export function parseCheckPathCommand(input: string): CheckPathCommand | undefin
|
|
|
39
44
|
return { action: action[1] as "add" | "remove", path };
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
function insideOrSame(parent: string, candidate: string): boolean {
|
|
43
|
-
const rel = relative(parent, candidate);
|
|
44
|
-
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
47
|
async function canonicalDirectory(input: string, cwd: string): Promise<string> {
|
|
48
48
|
const absolute = resolve(cwd, input);
|
|
49
49
|
const canonical = await realpath(absolute);
|
|
@@ -57,10 +57,10 @@ async function canonicalDirectory(input: string, cwd: string): Promise<string> {
|
|
|
57
57
|
realpath(AGENT_DIR).catch(() => resolve(AGENT_DIR)),
|
|
58
58
|
realpath(homedir()).catch(() => resolve(homedir())),
|
|
59
59
|
]);
|
|
60
|
-
if (
|
|
60
|
+
if (isPathInsideOrSame(canonical, agentDirectory) || isPathInsideOrSame(agentDirectory, canonical)) {
|
|
61
61
|
throw new Error("Check path cannot contain or enter PUM's configuration directory");
|
|
62
62
|
}
|
|
63
|
-
if (
|
|
63
|
+
if (isPathInsideOrSame(canonical, homeDirectory)) {
|
|
64
64
|
throw new Error("Check path cannot contain the home directory");
|
|
65
65
|
}
|
|
66
66
|
return canonical;
|
|
@@ -68,7 +68,7 @@ async function canonicalDirectory(input: string, cwd: string): Promise<string> {
|
|
|
68
68
|
|
|
69
69
|
async function removalIdentity(input: string, cwd: string): Promise<string> {
|
|
70
70
|
const absolute = resolve(cwd, input);
|
|
71
|
-
return
|
|
71
|
+
return canonicalPathIdentityAllowMissing(absolute);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export async function applyCheckPathCommand(
|
|
@@ -102,10 +102,13 @@ export async function applyCheckPathCommand(
|
|
|
102
102
|
: await removalIdentity(command.path, cwd);
|
|
103
103
|
const project = await realpath(cwd);
|
|
104
104
|
if (command.action === "add") {
|
|
105
|
-
if (
|
|
105
|
+
if (isPathInsideOrSame(project, canonical)) {
|
|
106
106
|
throw new Error("The directory is already inside the project boundary");
|
|
107
107
|
}
|
|
108
|
-
|
|
108
|
+
const identity = pathIdentity(canonical);
|
|
109
|
+
if (paths.some((path) => pathIdentity(path) === identity)) {
|
|
110
|
+
throw new Error(`Check path is already allowed: ${canonical}`);
|
|
111
|
+
}
|
|
109
112
|
if (paths.length >= MAX_CHECK_PATHS_PER_PROJECT) {
|
|
110
113
|
throw new Error(`Check mode allows at most ${MAX_CHECK_PATHS_PER_PROJECT} additional paths per project`);
|
|
111
114
|
}
|
|
@@ -117,7 +120,8 @@ export async function applyCheckPathCommand(
|
|
|
117
120
|
};
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
const
|
|
123
|
+
const identity = pathIdentity(canonical);
|
|
124
|
+
const index = paths.findIndex((path) => pathIdentity(path) === identity);
|
|
121
125
|
if (index < 0) throw new Error(`Check path is not configured: ${canonical}`);
|
|
122
126
|
const nextPaths = paths.filter((_path, candidate) => candidate !== index);
|
|
123
127
|
return {
|