pi-landstrip 0.11.7 → 0.11.9

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 +14 -5
  2. package/index.ts +187 -136
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -23,13 +23,22 @@ Project config takes precedence.
23
23
 
24
24
  See [`sandbox.json`](./sandbox.json) for a starter config.
25
25
 
26
- ## Usage
27
-
28
- ```bash
29
- pi --no-sandbox
26
+ Use Pi settings to toggle sandboxing:
27
+
28
+ ```json
29
+ {
30
+ "landstrip": {
31
+ "enabled": false
32
+ }
33
+ }
30
34
  ```
31
35
 
32
- Use `/sandbox` inside Pi to show the active config.
36
+ Project Pi settings override global Pi settings.
37
+ The `/sandbox` UI updates the project setting when present, otherwise the global setting.
38
+
39
+ ## Usage
40
+
41
+ Use `/sandbox` inside Pi to show the active config and toggle the Pi setting.
33
42
 
34
43
  ## License
35
44
 
package/index.ts CHANGED
@@ -62,6 +62,15 @@ interface SandboxConfig {
62
62
  filesystem: SandboxFilesystemConfig;
63
63
  }
64
64
 
65
+ interface PiLandstripSettings {
66
+ [key: string]: unknown;
67
+ landstrip?: {
68
+ enabled?: boolean;
69
+ };
70
+ }
71
+
72
+ type PiSettingsScope = 'global' | 'project';
73
+
65
74
  interface LandstripPolicy {
66
75
  network: {
67
76
  allowNetwork: boolean;
@@ -184,6 +193,53 @@ function loadConfig(cwd: string): SandboxConfig {
184
193
  return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
185
194
  }
186
195
 
196
+ function getPiLandstripSettings(cwd: string): {
197
+ globalSettings: PiLandstripSettings;
198
+ projectSettings: PiLandstripSettings;
199
+ } {
200
+ const settings = SettingsManager.create(cwd);
201
+ return {
202
+ globalSettings: settings.getGlobalSettings() as PiLandstripSettings,
203
+ projectSettings: settings.getProjectSettings() as PiLandstripSettings,
204
+ };
205
+ }
206
+
207
+ function isSandboxEnabledInPiSettings(cwd: string): boolean {
208
+ const { globalSettings, projectSettings } = getPiLandstripSettings(cwd);
209
+ return projectSettings.landstrip?.enabled ?? globalSettings.landstrip?.enabled ?? true;
210
+ }
211
+
212
+ function getPiSettingsWriteScope(cwd: string): PiSettingsScope {
213
+ const { projectSettings } = getPiLandstripSettings(cwd);
214
+ return projectSettings.landstrip?.enabled === undefined ? 'global' : 'project';
215
+ }
216
+
217
+ function getPiSettingsPath(cwd: string, scope: PiSettingsScope): string {
218
+ if (scope === 'project') return join(cwd, '.pi', 'settings.json');
219
+ return join(getAgentDir(), 'settings.json');
220
+ }
221
+
222
+ function readPiSettingsFile(settingsPath: string): PiLandstripSettings {
223
+ if (!existsSync(settingsPath)) return {};
224
+ return JSON.parse(readFileSync(settingsPath, 'utf-8')) as PiLandstripSettings;
225
+ }
226
+
227
+ async function setPiSettingsSandboxEnabled(
228
+ cwd: string,
229
+ enabled: boolean,
230
+ ): Promise<PiSettingsScope> {
231
+ const scope = getPiSettingsWriteScope(cwd);
232
+ const settingsPath = getPiSettingsPath(cwd, scope);
233
+ await withFileMutationQueue(settingsPath, async () => {
234
+ const settings = readPiSettingsFile(settingsPath);
235
+ settings.landstrip = { ...settings.landstrip, enabled };
236
+ mkdirSync(dirname(settingsPath), { recursive: true });
237
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
238
+ });
239
+
240
+ return scope;
241
+ }
242
+
187
243
  function mergeArray(base: string[], override?: string[]): string[] {
188
244
  if (!override) return base;
189
245
  return [...new Set([...base, ...override])];
@@ -1430,6 +1486,34 @@ export function createLandstripIntegration(
1430
1486
  return true;
1431
1487
  }
1432
1488
 
1489
+ let noSandboxFlag = false;
1490
+ function disableSandbox(ctx: ExtensionContext): void {
1491
+ sandboxEnabled = false;
1492
+ sandboxReady = false;
1493
+ setTuiStatus(ctx, 'sandbox', undefined);
1494
+ }
1495
+
1496
+ function ensureSandboxState(ctx: ExtensionContext): boolean {
1497
+ if (noSandboxFlag) {
1498
+ disableSandbox(ctx);
1499
+ return false;
1500
+ }
1501
+
1502
+ if (!isSandboxEnabledInPiSettings(ctx.cwd)) {
1503
+ disableSandbox(ctx);
1504
+ return false;
1505
+ }
1506
+
1507
+ const config = loadConfig(ctx.cwd);
1508
+ if (!config.enabled) {
1509
+ disableSandbox(ctx);
1510
+ return false;
1511
+ }
1512
+
1513
+ if (!sandboxEnabled || !sandboxReady) return enableSandbox(ctx);
1514
+ return true;
1515
+ }
1516
+
1433
1517
  function createBashTool(cwd: string, ctx?: ExtensionContext): LandstripBashTool {
1434
1518
  const localBash = createPlainBashTool(cwd);
1435
1519
 
@@ -1438,7 +1522,7 @@ export function createLandstripIntegration(
1438
1522
  label: 'bash (landstrip)',
1439
1523
  async execute(id, params, signal, onUpdate, callCtx) {
1440
1524
  const effectiveCtx = callCtx ?? ctx;
1441
- if (!sandboxEnabled || !sandboxReady || !effectiveCtx)
1525
+ if (!effectiveCtx || !ensureSandboxState(effectiveCtx))
1442
1526
  return localBash.execute(id, params, signal, onUpdate, effectiveCtx);
1443
1527
 
1444
1528
  return runBashWithOptionalRetry(id, params, signal, onUpdate, effectiveCtx);
@@ -1462,9 +1546,8 @@ export function createLandstripIntegration(
1462
1546
  if (shouldRegisterBashTool) pi.registerTool(createBashTool(localCwd));
1463
1547
 
1464
1548
  pi.on('user_bash', async (event, ctx) => {
1465
- if (!sandboxEnabled || !sandboxReady) return;
1549
+ if (!ensureSandboxState(ctx)) return;
1466
1550
  const config = loadConfig(ctx.cwd);
1467
- if (!config.enabled) return;
1468
1551
 
1469
1552
  if (!config.network.allowNetwork) {
1470
1553
  const blockedDomain = await preflightCommandDomains(event.command, ctx);
@@ -1484,10 +1567,9 @@ export function createLandstripIntegration(
1484
1567
  });
1485
1568
 
1486
1569
  pi.on('tool_call', async (event, ctx) => {
1487
- if (!sandboxEnabled) return;
1570
+ if (!ensureSandboxState(ctx)) return;
1488
1571
 
1489
1572
  const config = loadConfig(ctx.cwd);
1490
- if (!config.enabled) return;
1491
1573
 
1492
1574
  const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
1493
1575
 
@@ -1544,60 +1626,33 @@ export function createLandstripIntegration(
1544
1626
 
1545
1627
  pi.on('session_start', async (_event, ctx) => {
1546
1628
  resetSessionAllowances();
1547
- const noSandbox = maybePi.getFlag?.('no-sandbox') as boolean;
1629
+ noSandboxFlag = Boolean(maybePi.getFlag?.('no-sandbox'));
1548
1630
 
1549
- if (noSandbox) {
1550
- sandboxEnabled = false;
1551
- sandboxReady = false;
1631
+ if (noSandboxFlag) {
1632
+ disableSandbox(ctx);
1552
1633
  notify(ctx, 'Sandbox disabled via --no-sandbox', 'warning');
1553
1634
  return;
1554
1635
  }
1555
1636
 
1637
+ if (!isSandboxEnabledInPiSettings(ctx.cwd)) {
1638
+ disableSandbox(ctx);
1639
+ notify(ctx, 'Sandbox disabled via pi settings', 'info');
1640
+ return;
1641
+ }
1642
+
1556
1643
  const config = loadConfig(ctx.cwd);
1557
1644
  if (!config.enabled) {
1558
- sandboxEnabled = false;
1559
- sandboxReady = false;
1645
+ disableSandbox(ctx);
1560
1646
  notify(ctx, 'Sandbox disabled via config', 'info');
1561
1647
  return;
1562
1648
  }
1563
1649
 
1564
1650
  enableSandbox(ctx);
1565
1651
  });
1566
-
1567
- maybePi.registerCommand?.('sandbox-enable', {
1568
- description: 'Enable the landstrip sandbox for this session',
1569
- handler: async (_args, ctx) => {
1570
- if (sandboxEnabled) {
1571
- notify(ctx, 'Sandbox is already enabled', 'info');
1572
- return;
1573
- }
1574
-
1575
- if (enableSandbox(ctx)) notify(ctx, 'Sandbox enabled', 'info');
1576
- },
1577
- });
1578
-
1579
- maybePi.registerCommand?.('sandbox-disable', {
1580
- description: 'Disable the landstrip sandbox for this session',
1581
- handler: async (_args, ctx) => {
1582
- if (!sandboxEnabled) {
1583
- notify(ctx, 'Sandbox is already disabled', 'info');
1584
- return;
1585
- }
1586
-
1587
- sandboxEnabled = false;
1588
- sandboxReady = false;
1589
- setTuiStatus(ctx, 'sandbox', undefined);
1590
- notify(ctx, 'Sandbox disabled', 'info');
1591
- },
1592
- });
1593
-
1594
1652
  maybePi.registerCommand?.('sandbox', {
1595
1653
  description: 'Show sandbox configuration',
1596
1654
  handler: async (_args, ctx) => {
1597
- if (!sandboxEnabled) {
1598
- notify(ctx, 'Sandbox is disabled', 'info');
1599
- return;
1600
- }
1655
+ let piSettingsEnabled = isSandboxEnabledInPiSettings(ctx.cwd);
1601
1656
 
1602
1657
  const config = loadConfig(ctx.cwd);
1603
1658
  const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
@@ -1611,6 +1666,14 @@ export function createLandstripIntegration(
1611
1666
  const text = (s: string) => theme.fg('text', s);
1612
1667
  const borderFg = (s: string) => theme.fg('border', s);
1613
1668
 
1669
+ function sandboxStatus(): { color: 'success' | 'warning'; label: string } {
1670
+ if (noSandboxFlag) return { color: 'warning', label: 'Disabled (--no-sandbox)' };
1671
+ if (!piSettingsEnabled) return { color: 'warning', label: 'Disabled (Pi setting)' };
1672
+ if (!config.enabled) return { color: 'warning', label: 'Disabled (config)' };
1673
+ if (!sandboxEnabled || !sandboxReady) return { color: 'warning', label: 'Inactive' };
1674
+ return { color: 'success', label: 'Active' };
1675
+ }
1676
+
1614
1677
  function boolVal(v: boolean): string {
1615
1678
  return v ? theme.fg('warning', 'yes') : theme.fg('success', 'no');
1616
1679
  }
@@ -1623,120 +1686,108 @@ export function createLandstripIntegration(
1623
1686
 
1624
1687
  return {
1625
1688
  render(width: number): string[] {
1626
- const innerW = width - 4;
1689
+ const innerW = Math.max(1, width - 4);
1627
1690
  const border = borderFg('│');
1628
- const row = (c: string) => makeRow(c, innerW, border);
1691
+ const row = (content: string) => makeRow(content, innerW, border);
1629
1692
  const lines: string[] = [];
1693
+ const status = sandboxStatus();
1694
+ const toggleValue = piSettingsEnabled
1695
+ ? theme.fg('success', 'enabled')
1696
+ : theme.fg('warning', 'disabled');
1697
+
1698
+ function topBorder(titleText: string): string {
1699
+ const title = accent(` ${titleText} `);
1700
+ const fill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
1701
+ return `${borderFg('╭─')}${title}${fill}${borderFg('─╮')}`;
1702
+ }
1630
1703
 
1631
- // Top border
1632
- const title = accent(' Sandbox Configuration ');
1633
- const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
1634
- lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
1704
+ function section(titleText: string, detail?: string): void {
1705
+ lines.push(row(''));
1706
+ lines.push(row(`${accent(titleText)}${detail ? dim(` · ${detail}`) : ''}`));
1707
+ }
1635
1708
 
1636
- // Status
1637
- const statusDot = theme.fg('success', '●');
1638
- const pathSnippet = text(truncateToWidth(binaryPath(), Math.max(20, innerW - 27)));
1639
- lines.push(
1640
- row(
1641
- ` ${statusDot} ${text('Active')} ${dim('·')} ${muted('landstrip:')} ${pathSnippet}`,
1642
- ),
1643
- );
1709
+ function item(label: string, value: string): void {
1710
+ lines.push(row(` ${dim('')} ${muted(label.padEnd(13))} ${value}`));
1711
+ }
1644
1712
 
1645
- // Config files
1646
- lines.push(row(` ${dim('Config files:')}`));
1647
- lines.push(row(` ${dim('project')} ${text(projectPath)}`));
1648
- lines.push(row(` ${dim('global')} ${text(globalPath)}`));
1713
+ function listValue(values: string[], maxWidth: number): string {
1714
+ const value = values.join(', ') || 'none';
1715
+ return text(truncateToWidth(value, Math.max(10, maxWidth)));
1716
+ }
1649
1717
 
1650
- // Network section
1651
- lines.push(row(''));
1652
- lines.push(row(`${''.repeat(innerW)}`));
1653
- const netMode = config.network.allowNetwork ? ' (unrestricted)' : ' (proxied)';
1654
- lines.push(row(` ${accent('Network')}${dim(netMode)}`));
1655
- lines.push(
1656
- row(
1657
- ` ${dim('•')} ${muted('Allow network:')} ${boolVal(config.network.allowNetwork)}`,
1658
- ),
1659
- );
1660
- const domainsStr = config.network.allowedDomains.join(', ') || '(none)';
1661
- lines.push(
1662
- row(
1663
- ` ${dim('•')} ${muted('Allowed:')} ${text(truncateToWidth(domainsStr, Math.max(10, innerW - 15)))}`,
1664
- ),
1665
- );
1666
- const denyStr = config.network.deniedDomains.join(', ') || '(none)';
1718
+ lines.push(topBorder('Sandbox'));
1719
+
1720
+ const statusDot = theme.fg(status.color, '');
1721
+ const pathSnippet = text(truncateToWidth(binaryPath(), Math.max(20, innerW - 28)));
1667
1722
  lines.push(
1668
1723
  row(
1669
- ` ${dim('')} ${muted('Denied:')} ${text(truncateToWidth(denyStr, Math.max(10, innerW - 14)))}`,
1724
+ `${statusDot} ${text(status.label)} ${dim('·')} Pi setting ${toggleValue} ${dim('·')} ${muted('landstrip')} ${pathSnippet}`,
1670
1725
  ),
1671
1726
  );
1672
- if (sessionAllowedDomains.length > 0) {
1673
- lines.push(
1674
- row(
1675
- ` ${dim('')} ${muted('Session:')} ${theme.fg('accent', sessionAllowedDomains.join(', '))}`,
1676
- ),
1677
- );
1727
+
1728
+ section('Config');
1729
+ item('project', text(projectPath));
1730
+ item('global', text(globalPath));
1731
+
1732
+ const netMode = config.network.allowNetwork ? 'unrestricted' : 'proxied';
1733
+ section('Network', netMode);
1734
+ item('allow network', boolVal(config.network.allowNetwork));
1735
+ item('allowed', listValue(config.network.allowedDomains, innerW - 17));
1736
+ item('denied', listValue(config.network.deniedDomains, innerW - 17));
1737
+ if (sessionAllowedDomains.length > 0)
1738
+ item('session', theme.fg('accent', sessionAllowedDomains.join(', ')));
1739
+
1740
+ section('Filesystem');
1741
+ item('deny read', listValue(config.filesystem.denyRead, innerW - 17));
1742
+ item('allow read', listValue(config.filesystem.allowRead, innerW - 17));
1743
+ item('allow write', listValue(config.filesystem.allowWrite, innerW - 17));
1744
+ item('deny write', listValue(config.filesystem.denyWrite, innerW - 17));
1745
+
1746
+ if (sessionAllowedReadPaths.length > 0 || sessionAllowedWritePaths.length > 0) {
1747
+ section('Session grants');
1748
+ if (sessionAllowedReadPaths.length > 0)
1749
+ item('read', theme.fg('accent', sessionAllowedReadPaths.join(', ')));
1750
+ if (sessionAllowedWritePaths.length > 0)
1751
+ item('write', theme.fg('accent', sessionAllowedWritePaths.join(', ')));
1678
1752
  }
1679
1753
 
1680
- // Filesystem section
1681
1754
  lines.push(row(''));
1682
- lines.push(row(`${'─'.repeat(innerW)}`));
1683
- lines.push(row(` ${accent('Filesystem')}`));
1684
- const denyReadStr = config.filesystem.denyRead.join(', ') || '(none)';
1685
1755
  lines.push(
1686
- row(
1687
- ` ${dim('•')} ${muted('Deny read:')} ${text(truncateToWidth(denyReadStr, Math.max(10, innerW - 16)))}`,
1688
- ),
1689
- );
1690
- const allowReadStr = config.filesystem.allowRead.join(', ') || '(none)';
1691
- lines.push(
1692
- row(
1693
- ` ${dim('•')} ${muted('Allow read:')} ${text(truncateToWidth(allowReadStr, Math.max(10, innerW - 17)))}`,
1694
- ),
1756
+ row(`${dim('t')} ${muted('toggle Pi setting')} ${dim('esc')} ${muted('close')}`),
1695
1757
  );
1696
- const allowWriteStr = config.filesystem.allowWrite.join(', ') || '(none)';
1697
1758
  lines.push(
1698
- row(
1699
- ` ${dim('•')} ${muted('Allow write:')} ${text(truncateToWidth(allowWriteStr, Math.max(10, innerW - 18)))}`,
1700
- ),
1701
- );
1702
- const denyWriteStr = config.filesystem.denyWrite.join(', ') || '(none)';
1703
- lines.push(
1704
- row(
1705
- ` ${dim('•')} ${muted('Deny write:')} ${text(truncateToWidth(denyWriteStr, Math.max(10, innerW - 17)))}`,
1706
- ),
1759
+ `${borderFg('╰')}${borderFg('─'.repeat(Math.max(0, width - 2)))}${borderFg('╯')}`,
1707
1760
  );
1708
1761
 
1709
- // Session allowances
1710
- if (sessionAllowedReadPaths.length > 0 || sessionAllowedWritePaths.length > 0) {
1711
- lines.push(row(''));
1712
- if (sessionAllowedReadPaths.length > 0) {
1713
- lines.push(
1714
- row(
1715
- ` ${dim('•')} ${muted('Session read:')} ${theme.fg('accent', sessionAllowedReadPaths.join(', '))}`,
1716
- ),
1717
- );
1718
- }
1719
- if (sessionAllowedWritePaths.length > 0) {
1720
- lines.push(
1721
- row(
1722
- ` ${dim('•')} ${muted('Session write:')} ${theme.fg('accent', sessionAllowedWritePaths.join(', '))}`,
1723
- ),
1724
- );
1725
- }
1726
- }
1727
-
1728
- // Footer
1729
- lines.push(row(''));
1730
- lines.push(row(` ${dim('esc')} ${muted('or any key to close')}`));
1731
-
1732
- // Bottom border
1733
- lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
1734
-
1735
1762
  return lines;
1736
1763
  },
1737
1764
 
1738
- handleInput(): void {
1739
- done(undefined);
1765
+ handleInput(data: string): void {
1766
+ if (data !== 't' && data !== 'T') {
1767
+ done(undefined);
1768
+ return;
1769
+ }
1770
+
1771
+ void (async () => {
1772
+ const enabled = !piSettingsEnabled;
1773
+ const scope = await setPiSettingsSandboxEnabled(ctx.cwd, enabled);
1774
+ piSettingsEnabled = isSandboxEnabledInPiSettings(ctx.cwd);
1775
+
1776
+ if (!enabled) {
1777
+ disableSandbox(ctx);
1778
+ notify(ctx, `Sandbox disabled via ${scope} Pi settings`, 'info');
1779
+ } else if (noSandboxFlag) {
1780
+ notify(ctx, 'Sandbox remains disabled via --no-sandbox', 'warning');
1781
+ } else if (!config.enabled) {
1782
+ notify(ctx, 'Sandbox remains disabled via config', 'info');
1783
+ } else if (enableSandbox(ctx)) {
1784
+ notify(ctx, `Sandbox enabled via ${scope} Pi settings`, 'info');
1785
+ }
1786
+
1787
+ tui.requestRender();
1788
+ })().catch((error: unknown) => {
1789
+ notify(ctx, `Could not update Pi settings: ${error}`, 'error');
1790
+ });
1740
1791
  },
1741
1792
 
1742
1793
  invalidate(): void {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.11.7",
3
+ "version": "0.11.9",
4
4
  "description": "Landlock-based sandboxing for pi with interactive permission prompts",
5
5
  "keywords": [
6
6
  "landstrip",