pi-landstrip 0.18.18 → 0.18.20

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
@@ -32,12 +32,14 @@ the sandbox is explicitly disabled.
32
32
 
33
33
  The root Pi process is trusted. Pi filesystem tools and plugin callbacks run
34
34
  outside the OS sandbox. Agent permissions still control tool dispatch, but they
35
- are not an OS isolation boundary.
35
+ are not an OS isolation boundary. On Linux and macOS, primary Bash reads therefore
36
+ use the same host view by default; writes remain sandboxed. Subagent processes
37
+ retain the configured read and write policy.
36
38
 
37
- The default [sandbox policy](./sandbox.json) limits reads and writes to the
38
- project and a few bootstrap files. Direct network access is blocked. When the
39
- platform permits loopback, the extension's proxy filters allowed HTTP and HTTPS
40
- domains.
39
+ The default [sandbox policy](./sandbox.json) limits writes to the project and
40
+ restricts worker reads within the user's home to the project and a few bootstrap
41
+ files. Direct network access is blocked. When the platform permits loopback, the
42
+ extension's proxy filters allowed HTTP and HTTPS domains.
41
43
 
42
44
  When a command needs more access, Pi can allow it once, for the session, or in
43
45
  the project or global policy. Project approvals go to `.pi/sandbox.json` and
@@ -61,6 +63,15 @@ Subagents remain separate processes but lose Landstrip OS isolation. Pi warns
61
63
  once per session. Trusted project config overrides global config; `/sandbox`
62
64
  updates a project sandbox file when present, otherwise the global file.
63
65
 
66
+ ### Shell read access
67
+
68
+ `"shell": { "readAccess": "host" }` keeps primary Bash and `!`/`!!` reads
69
+ consistent with trusted Pi filesystem tools. Set `readAccess` to `"policy"` to
70
+ apply `filesystem.denyRead` and `filesystem.allowRead` to shell commands as an
71
+ intentional stricter boundary. Worker processes always use the filesystem read
72
+ policy. Windows always uses policy reads because its sandbox requires an explicit
73
+ read allowlist.
74
+
64
75
  ### Permission layers
65
76
 
66
77
  | Layer | Checked | Controls |
package/dist/index.ts CHANGED
@@ -3909,6 +3909,15 @@ function registerSubagents(pi, integration) {
3909
3909
  }
3910
3910
 
3911
3911
  // index.ts
3912
+ function enforcesShellReadPolicy(shellReadAccess, platform = process.platform) {
3913
+ return shellReadAccess !== "host" || platform === "win32";
3914
+ }
3915
+ function resolveProcessReadPolicy(audience, shellReadAccess, denyRead, allowRead, platform = process.platform) {
3916
+ if (audience === "shell" && !enforcesShellReadPolicy(shellReadAccess, platform)) {
3917
+ return { denyRead: [], allowRead: [] };
3918
+ }
3919
+ return { denyRead, allowRead };
3920
+ }
3912
3921
  var SUPPORTED_PLATFORMS = new Set(["linux", "darwin", "win32"]);
3913
3922
  var prohibitedProxyAddresses = new BlockList;
3914
3923
  for (const [network, prefix] of [
@@ -3998,11 +4007,15 @@ function mergeArray(base, override) {
3998
4007
  return [...new Set([...base, ...override])];
3999
4008
  }
4000
4009
  function deepMerge(base, overrides) {
4010
+ const shell = overrides.shell;
4001
4011
  const network = overrides.network;
4002
4012
  const filesystem = overrides.filesystem;
4003
4013
  const windows = overrides.windows;
4004
4014
  return {
4005
4015
  enabled: overrides.enabled ?? base.enabled,
4016
+ shell: {
4017
+ readAccess: shell?.readAccess ?? base.shell.readAccess
4018
+ },
4006
4019
  network: {
4007
4020
  allowNetwork: network?.allowNetwork ?? base.network.allowNetwork,
4008
4021
  allowLocalBinding: network?.allowLocalBinding ?? base.network.allowLocalBinding,
@@ -4247,10 +4260,10 @@ function longestPrefixMatch(path, patterns, cwd) {
4247
4260
  return best;
4248
4261
  }
4249
4262
  function readAllowed(path, allowRead, denyRead, cwd) {
4250
- const allow = longestPrefixMatch(path, allowRead, cwd);
4251
- if (allow < 0)
4252
- return false;
4253
- return allow >= longestPrefixMatch(path, denyRead, cwd);
4263
+ const deny = longestPrefixMatch(path, denyRead, cwd);
4264
+ if (deny < 0)
4265
+ return true;
4266
+ return longestPrefixMatch(path, allowRead, cwd) >= deny;
4254
4267
  }
4255
4268
  function isPathLike(value) {
4256
4269
  const trimmed = value.trim();
@@ -4288,6 +4301,10 @@ function extractNativeDeniedPath(output, cwd) {
4288
4301
  return normalizePathMatch(match[1], cwd);
4289
4302
  return null;
4290
4303
  }
4304
+ function extractRetryableNativeReadDeniedPath(output, cwd, allowRead, denyRead) {
4305
+ const path = extractNativeDeniedPath(output, cwd);
4306
+ return path && !readAllowed(path, allowRead, denyRead, cwd) ? path : null;
4307
+ }
4291
4308
  function extractNativeWriteDeniedPath(output, cwd) {
4292
4309
  const denial = String.raw`(?:Access is denied\.?|Operation not permitted|Permission denied)`;
4293
4310
  let match = output.match(new RegExp(String.raw`(?:[Uu]nable to create|failed to create(?: directory)?|cannot (?:create|touch|mkdir|remove|unlink|rename)|for writing)[^'"\x60\r\n]*['"\x60]([^'"\x60\r\n]+)['"\x60](?:[^\r\n]*\r?\n){0,3}[^\r\n]{0,120}${denial}`, "im"));
@@ -4845,8 +4862,9 @@ function createLandstripIntegration(options = {}) {
4845
4862
  return true;
4846
4863
  }, signal);
4847
4864
  }
4848
- function buildLandstripPolicy(cwd, proxyPort, allowances) {
4865
+ function buildLandstripPolicy(cwd, proxyPort, audience, allowances) {
4849
4866
  const config = loadConfig(cwd);
4867
+ const readPolicy = resolveProcessReadPolicy(audience, config.shell.readAccess, getEffectiveDenyRead(config, cwd), getEffectiveAllowRead(config, cwd, allowances));
4850
4868
  return {
4851
4869
  network: {
4852
4870
  allowNetwork: config.network.allowNetwork,
@@ -4856,18 +4874,17 @@ function createLandstripIntegration(options = {}) {
4856
4874
  ...proxyPort !== null ? { httpProxyPort: proxyPort } : {}
4857
4875
  },
4858
4876
  filesystem: {
4859
- denyRead: getEffectiveDenyRead(config, cwd),
4860
- allowRead: getEffectiveAllowRead(config, cwd, allowances),
4877
+ ...readPolicy,
4861
4878
  allowWrite: getEffectiveAllowWrite(config, allowances),
4862
4879
  denyWrite: config.filesystem.denyWrite
4863
4880
  },
4864
4881
  windows: config.windows
4865
4882
  };
4866
4883
  }
4867
- function writePolicyFile(cwd, proxyPort, allowances) {
4884
+ function writePolicyFile(cwd, proxyPort, audience, allowances) {
4868
4885
  const dir = mkdtempSync2(join7(tmpdir2(), "pi-landstrip-"));
4869
4886
  const path = join7(dir, "policy.json");
4870
- writeFileSync4(path, JSON.stringify(buildLandstripPolicy(cwd, proxyPort, allowances), null, 2) + `
4887
+ writeFileSync4(path, JSON.stringify(buildLandstripPolicy(cwd, proxyPort, audience, allowances), null, 2) + `
4871
4888
  `, "utf-8");
4872
4889
  return { dir, path };
4873
4890
  }
@@ -5078,7 +5095,7 @@ function createLandstripIntegration(options = {}) {
5078
5095
  if (trap.operation === "write" && matchesPattern(path, config.filesystem.denyWrite, cwd)) {
5079
5096
  return { action: "deny", reason: "hard-deny" };
5080
5097
  }
5081
- const allowed = trap.operation === "read" ? readAllowed(path, getEffectiveAllowRead(config, cwd, allowances), config.filesystem.denyRead, cwd) : !shouldPromptForWrite(path, getEffectiveAllowWrite(config, allowances), cwd);
5098
+ const allowed = trap.operation === "read" ? readAllowed(path, getEffectiveAllowRead(config, cwd, allowances), getEffectiveDenyRead(config, cwd), cwd) : !shouldPromptForWrite(path, getEffectiveAllowWrite(config, allowances), cwd);
5082
5099
  return allowed ? { action: "allow" } : undefined;
5083
5100
  };
5084
5101
  const existing = current();
@@ -5213,7 +5230,7 @@ function createLandstripIntegration(options = {}) {
5213
5230
  try {
5214
5231
  if (options2.signal?.aborted)
5215
5232
  throw new Error("Task cancelled");
5216
- policy = writePolicyFile(options2.cwd, proxy?.port ?? null, processAllowances);
5233
+ policy = writePolicyFile(options2.cwd, proxy?.port ?? null, "worker", processAllowances);
5217
5234
  if (process.platform !== "win32") {
5218
5235
  [trapSocket, childEnd] = await createSocketPair();
5219
5236
  if (options2.signal?.aborted)
@@ -5347,7 +5364,7 @@ function createLandstripIntegration(options = {}) {
5347
5364
  readPaths: [...allowances.readPaths, ...invocation.readPaths ?? []]
5348
5365
  };
5349
5366
  processAllowances = withWindowsProcessReadAccess(processAllowances, invocation.executable, cwd);
5350
- policy = writePolicyFile(cwd, proxy?.port ?? null, processAllowances);
5367
+ policy = writePolicyFile(cwd, proxy?.port ?? null, "shell", processAllowances);
5351
5368
  landstripArgs = [
5352
5369
  "run",
5353
5370
  "-p",
@@ -5565,6 +5582,15 @@ function createLandstripIntegration(options = {}) {
5565
5582
  shellPath: SettingsManager.create(ctx.cwd).getShellPath()
5566
5583
  });
5567
5584
  const run = () => sandboxedBash.execute(id, params, signal, onUpdate, ctx);
5585
+ const blockedReadPathForRetry = (nativeOutput) => {
5586
+ const config = loadConfig(ctx.cwd);
5587
+ if (!enforcesShellReadPolicy(config.shell.readAccess))
5588
+ return null;
5589
+ const trappedPath = extractTrapBlockedPath(landstripErrorOutput, ctx.cwd, "read");
5590
+ if (trappedPath)
5591
+ return trappedPath;
5592
+ return extractRetryableNativeReadDeniedPath(nativeOutput, ctx.cwd, getEffectiveAllowRead(config, ctx.cwd, allowances), getEffectiveDenyRead(config, ctx.cwd));
5593
+ };
5568
5594
  const retryWithAccess = async (operation, blockedPath) => {
5569
5595
  if (!ctx.hasUI)
5570
5596
  return null;
@@ -5574,7 +5600,7 @@ function createLandstripIntegration(options = {}) {
5574
5600
  if (operation === "write" && matchesPattern(blockedPath, config2.filesystem.denyWrite, ctx.cwd)) {
5575
5601
  return false;
5576
5602
  }
5577
- const needsPrompt = operation === "read" ? !matchesPattern(blockedPath, getEffectiveAllowRead(config2, ctx.cwd, allowances), ctx.cwd) : shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config2, allowances), ctx.cwd);
5603
+ const needsPrompt = operation === "read" ? !readAllowed(blockedPath, getEffectiveAllowRead(config2, ctx.cwd, allowances), getEffectiveDenyRead(config2, ctx.cwd), ctx.cwd) : shouldPromptForWrite(blockedPath, getEffectiveAllowWrite(config2, allowances), ctx.cwd);
5578
5604
  return needsPrompt ? undefined : true;
5579
5605
  };
5580
5606
  const access = await permissionPrompts.resolve(current, async () => {
@@ -5633,7 +5659,7 @@ ${errorText}`;
5633
5659
  if (retryResult2)
5634
5660
  return retryResult2;
5635
5661
  }
5636
- const blockedReadPath2 = extractTrapBlockedPath(landstripErrorOutput, ctx.cwd, "read") ?? extractNativeDeniedPath(fallbackOutput, ctx.cwd);
5662
+ const blockedReadPath2 = blockedReadPathForRetry(fallbackOutput);
5637
5663
  if (blockedReadPath2) {
5638
5664
  const retryResult2 = await retryWithAccess("read", blockedReadPath2);
5639
5665
  if (retryResult2)
@@ -5658,7 +5684,7 @@ ${message}
5658
5684
  if (retryResult2)
5659
5685
  return retryResult2;
5660
5686
  }
5661
- const blockedReadPath = extractTrapBlockedPath(landstripErrorOutput, ctx.cwd, "read") ?? extractNativeDeniedPath(stderrOutput, ctx.cwd);
5687
+ const blockedReadPath = blockedReadPathForRetry(stderrOutput);
5662
5688
  if (!blockedReadPath)
5663
5689
  return result;
5664
5690
  const retryResult = await retryWithAccess("read", blockedReadPath);
@@ -5871,6 +5897,8 @@ ${message}
5871
5897
  return { color: "success", label: "Active" };
5872
5898
  };
5873
5899
  const networkMode = config.network.allowNetwork ? "Unrestricted" : windowsImplementation === "appContainer" && !config.windows.allowLoopback ? "Blocked" : "Proxied";
5900
+ const shellReadMode = process.platform === "win32" ? "Policy (required)" : config.shell.readAccess === "host" ? "Host-aligned" : "Policy";
5901
+ const readRuleScope = enforcesShellReadPolicy(config.shell.readAccess) ? "Workers and shell" : "Workers only";
5874
5902
  return {
5875
5903
  render(width) {
5876
5904
  const innerWidth = Math.max(1, width - 4);
@@ -5888,12 +5916,12 @@ ${message}
5888
5916
  ]);
5889
5917
  const content = [tabs, ""];
5890
5918
  if (tab === "Overview") {
5891
- content.push(`${theme.fg(status.color, "●")} ${text(status.label)}`, item("Configured", config.enabled ? text("Enabled") : theme.fg("warning", "Disabled")), item("Runtime", sandboxEnabled && sandboxReady ? theme.fg("success", "Running") : muted("Not running")), "", accent("Protection"), item("Network", text(networkMode)), item("Filesystem", text(`${config.filesystem.denyRead.length + config.filesystem.allowRead.length} read rules, ${config.filesystem.allowWrite.length + config.filesystem.denyWrite.length} write rules`)), item("Session grants", text(String(sessionAllowedDomains.length + sessionAllowedReadPaths.length + sessionAllowedWritePaths.length))), "", accent("Configuration"), item("Change scope", text(target.scope === "project" ? "Project" : "Global")), item("Change file", text(truncateToWidth3(target.path, Math.max(10, innerWidth - 20)))), item("Project file", text(truncateToWidth3(projectPath, Math.max(10, innerWidth - 20)))), item("Global file", text(truncateToWidth3(globalPath, Math.max(10, innerWidth - 20)))), item("Landstrip binary", text(truncateToWidth3(landstripDisplayPath(), Math.max(10, innerWidth - 20)))));
5919
+ content.push(`${theme.fg(status.color, "●")} ${text(status.label)}`, item("Configured", config.enabled ? text("Enabled") : theme.fg("warning", "Disabled")), item("Runtime", sandboxEnabled && sandboxReady ? theme.fg("success", "Running") : muted("Not running")), "", accent("Protection"), item("Network", text(networkMode)), item("Shell reads", text(shellReadMode)), item("Read rules", text(`${config.filesystem.denyRead.length + config.filesystem.allowRead.length} · ${readRuleScope}`)), item("Write rules", text(String(config.filesystem.allowWrite.length + config.filesystem.denyWrite.length))), item("Session grants", text(String(sessionAllowedDomains.length + sessionAllowedReadPaths.length + sessionAllowedWritePaths.length))), "", accent("Configuration"), item("Change scope", text(target.scope === "project" ? "Project" : "Global")), item("Change file", text(truncateToWidth3(target.path, Math.max(10, innerWidth - 20)))), item("Project file", text(truncateToWidth3(projectPath, Math.max(10, innerWidth - 20)))), item("Global file", text(truncateToWidth3(globalPath, Math.max(10, innerWidth - 20)))), item("Landstrip binary", text(truncateToWidth3(landstripDisplayPath(), Math.max(10, innerWidth - 20)))));
5892
5920
  if (windowsImplementation !== undefined) {
5893
5921
  content.push(item("Windows mode", text(windowsImplementation === "restrictedUser" ? "Restricted user" : `AppContainer (${config.windows.appContainerMode})`)));
5894
5922
  }
5895
5923
  } else {
5896
- content.push(accent(`Network · ${networkMode}`), item("Allowed domains", listValue(config.network.allowedDomains)), item("Denied domains", listValue(config.network.deniedDomains)), item("Unix sockets", config.network.allowAllUnixSockets ? text("all") : listValue(config.network.allowUnixSockets)), "", accent("Filesystem"), item("Denied reads", listValue(config.filesystem.denyRead)), item("Allowed reads", listValue(config.filesystem.allowRead)), item("Allowed writes", listValue(config.filesystem.allowWrite)), item("Denied writes", listValue(config.filesystem.denyWrite)), "", accent("Session grants"), item("Domains", listValue(sessionAllowedDomains)), item("Read paths", listValue(sessionAllowedReadPaths)), item("Write paths", listValue(sessionAllowedWritePaths)));
5924
+ content.push(accent(`Network · ${networkMode}`), item("Allowed domains", listValue(config.network.allowedDomains)), item("Denied domains", listValue(config.network.deniedDomains)), item("Unix sockets", config.network.allowAllUnixSockets ? text("all") : listValue(config.network.allowUnixSockets)), "", accent("Filesystem"), item("Shell reads", text(shellReadMode)), item("Read rules for", text(readRuleScope)), item("Denied reads", listValue(config.filesystem.denyRead)), item("Allowed reads", listValue(config.filesystem.allowRead)), item("Allowed writes", listValue(config.filesystem.allowWrite)), item("Denied writes", listValue(config.filesystem.denyWrite)), "", accent("Session grants"), item("Domains", listValue(sessionAllowedDomains)), item("Read paths", listValue(sessionAllowedReadPaths)), item("Write paths", listValue(sessionAllowedWritePaths)));
5897
5925
  }
5898
5926
  content.push("", footer);
5899
5927
  return [
@@ -5983,14 +6011,17 @@ export {
5983
6011
  writeEnvFile,
5984
6012
  shouldPromptForWrite,
5985
6013
  sessionScopeFor,
6014
+ resolveProcessReadPolicy,
5986
6015
  readAllowed,
5987
6016
  parseTrapLine,
5988
6017
  matchesPattern,
5989
6018
  isQueryTrap,
5990
6019
  isPublicProxyAddress,
5991
6020
  formatLandstripTraps,
6021
+ extractRetryableNativeReadDeniedPath,
5992
6022
  extractNativeWriteDeniedPath,
5993
6023
  extractNativeDeniedPath,
6024
+ enforcesShellReadPolicy,
5994
6025
  domainMatchesAny,
5995
6026
  pi_landstrip_default as default,
5996
6027
  createWindowsDenyRead,
package/dist/sandbox.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "enabled": true,
3
+ "shell": {
4
+ "readAccess": "host"
5
+ },
3
6
  "network": {
4
7
  "allowNetwork": false,
5
8
  "allowLocalBinding": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.18.18",
3
+ "version": "0.18.20",
4
4
  "description": "Sandboxed Bash and process-backed agents for Pi",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -39,7 +39,7 @@
39
39
  "ci:test": "npm run test"
40
40
  },
41
41
  "dependencies": {
42
- "@landstrip/landstrip": "^0.18.18",
42
+ "@landstrip/landstrip": "^0.18.20",
43
43
  "jsonc-parser": "^3.3.1",
44
44
  "minimatch": "^10.2.5",
45
45
  "yaml": "^2.9.0"
package/sandbox.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "enabled": true,
3
+ "shell": {
4
+ "readAccess": "host"
5
+ },
3
6
  "network": {
4
7
  "allowNetwork": false,
5
8
  "allowLocalBinding": false,