@olahulleberg/infer 0.2.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +10 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +59 -3
package/README.md CHANGED
@@ -95,6 +95,16 @@ The classifier uses the model you configure — no separate API key needed. If i
95
95
 
96
96
  To set it up: run `infer config` and answer yes to the classifier prompt at the end.
97
97
 
98
+ ## Sandbox
99
+
100
+ When a classifier is configured, auto-approved commands run inside a sandbox that makes the filesystem read-only. The command can read anything but cannot write, delete, or modify files.
101
+
102
+ **Linux:** requires [`bwrap`](https://github.com/containers/bubblewrap) (`sudo apt install bubblewrap` / `sudo pacman -S bubblewrap`)
103
+
104
+ **macOS:** uses `sandbox-exec`, which is built-in.
105
+
106
+ If the classifier is configured but no sandbox is detected, infer warns at startup and falls back to running auto-approved commands without isolation.
107
+
98
108
  ## Sessions
99
109
 
100
110
  - Default: fresh session, clears previous
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olahulleberg/infer",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Minimal Pi-powered CLI for one-shot prompts",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -24,7 +24,7 @@ import { completeSimple } from "@mariozechner/pi-ai";
24
24
  import { homedir } from "os";
25
25
  import { join, resolve } from "path";
26
26
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "fs";
27
- import { spawn } from "child_process";
27
+ import { spawn, execFileSync } from "child_process";
28
28
 
29
29
  const VALID_THINKING_LEVELS = new Set([
30
30
  "off",
@@ -149,11 +149,26 @@ if (!prompt) {
149
149
  if (!options.continue) {
150
150
  clearSessions(sessionDir);
151
151
  }
152
+
153
+ const classifierConfig = loadClassifierConfig(agentDir);
154
+ const sandboxBin = detectSandbox();
155
+
156
+ if (classifierConfig && !sandboxBin) {
157
+ process.stderr.write(
158
+ gray(
159
+ "Warning: classifier is active but no sandbox detected. Auto-approved commands run without isolation.\n" +
160
+ " Linux: install bubblewrap (bwrap) | macOS: sandbox-exec should be built-in\n",
161
+ ),
162
+ );
163
+ }
164
+
165
+ const sandboxState = { next: false };
166
+
152
167
  const resourceLoader = new DefaultResourceLoader({
153
168
  cwd: process.cwd(),
154
169
  agentDir,
155
170
  settingsManager,
156
- extensionFactories: [createBashApprovalExtension({ modelRegistry, classifierConfig: loadClassifierConfig(agentDir) })],
171
+ extensionFactories: [createBashApprovalExtension({ modelRegistry, classifierConfig, sandboxBin, sandboxState })],
157
172
  });
158
173
 
159
174
  await resourceLoader.reload();
@@ -178,6 +193,20 @@ const { session } = await createAgentSession({
178
193
  thinkingLevel: options.thinking,
179
194
  });
180
195
 
196
+ if (sandboxBin && classifierConfig) {
197
+ const bt = session.agent.state.tools.find((t) => t.name === "bash");
198
+ if (bt) {
199
+ const orig = bt.execute;
200
+ bt.execute = async (id, args, signal, progress) => {
201
+ if (sandboxState.next) {
202
+ sandboxState.next = false;
203
+ args = { ...args, command: wrapWithSandbox(args.command, sandboxBin) };
204
+ }
205
+ return orig(id, args, signal, progress);
206
+ };
207
+ }
208
+ }
209
+
181
210
  let lastAssistantText = "";
182
211
  let printedToolLine = false;
183
212
 
@@ -627,6 +656,32 @@ function formatContext(value) {
627
656
  return String(value);
628
657
  }
629
658
 
659
+ function detectSandbox() {
660
+ try {
661
+ if (process.platform === "linux") {
662
+ execFileSync("which", ["bwrap"], { stdio: "ignore" });
663
+ return "bwrap";
664
+ }
665
+ if (process.platform === "darwin") {
666
+ execFileSync("which", ["sandbox-exec"], { stdio: "ignore" });
667
+ return "sandbox-exec";
668
+ }
669
+ } catch {}
670
+ return null;
671
+ }
672
+
673
+ function wrapWithSandbox(command, sandboxBin) {
674
+ const q = "'" + command.replace(/'/g, "'\\''") + "'";
675
+ if (sandboxBin === "bwrap") {
676
+ return `bwrap --ro-bind / / --dev-bind /dev /dev --proc /proc --tmpfs /tmp -- sh -c ${q}`;
677
+ }
678
+ if (sandboxBin === "sandbox-exec") {
679
+ const profile = "(version 1)(deny default)(allow file-read* file-map-executable process-exec process-fork signal sysctl-read mach-lookup)";
680
+ return `sandbox-exec -p '${profile}' sh -c ${q}`;
681
+ }
682
+ return command;
683
+ }
684
+
630
685
  function loadClassifierConfig(agentDir) {
631
686
  const file = join(agentDir, "classifier.json");
632
687
  if (!existsSync(file)) return null;
@@ -652,7 +707,7 @@ function gray(text) {
652
707
  return `${DIM}${text}${RESET}`;
653
708
  }
654
709
 
655
- function createBashApprovalExtension({ modelRegistry, classifierConfig }) {
710
+ function createBashApprovalExtension({ modelRegistry, classifierConfig, sandboxBin, sandboxState }) {
656
711
  let allowAll = false;
657
712
 
658
713
  return (pi) => {
@@ -677,6 +732,7 @@ function createBashApprovalExtension({ modelRegistry, classifierConfig }) {
677
732
  const classification = await classifyCommand(command, { modelRegistry, classifierConfig });
678
733
  if (classification.harmless) {
679
734
  process.stdout.write(gray(`✓ ${classification.description}\n`));
735
+ if (sandboxBin) sandboxState.next = true;
680
736
  return;
681
737
  }
682
738
  let decision;