Skip to content

Commit 56bb0aa

Browse files
committed
feat(storage): enforce_private_permissions opt-out for trusted-group shared databases (#252)
2 parents 410c1bd + 1de11c7 commit 56bb0aa

19 files changed

Lines changed: 326 additions & 63 deletions

CONFIGURATION.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ So memories you write in OpenCode appear in Pi sessions for the same project (an
2626

2727
For semantic search to work cross-harness, both plugins resolve embedding config per project identity on every retrieval path. OpenCode and Pi can run in the same process against different projects without sharing one process-global embedding provider. For one project, keep the effective `embedding` block consistent across the OpenCode and Pi config stack; Magic Context tags stored vectors with the resolved model identity and clears stale vectors for that project when the provider/model changes.
2828

29+
### Trusted-group shared storage
30+
31+
By default, Magic Context enforces owner-only `0700` storage directories and `0600` storage files. For a deliberate Unix deployment where trusted users share one store and an operator manages permissions externally, set this **in user config only**:
32+
33+
```jsonc
34+
{
35+
"storage": {
36+
"enforce_private_permissions": false
37+
}
38+
}
39+
```
40+
41+
For example, the operator may maintain the storage directory as `2770` and `context.db`, `context.db-wal`, and `context.db-shm` as `0660` for a trusted Unix group. With this setting disabled, Magic Context never re-tightens directory, database, WAL/SHM, model-cache, or RPC-file permissions; missing paths are still created using the operator's umask. Every group member that can read this store can read **all** stored session content and memories, so use this only for a deliberately trusted group. On Windows, POSIX modes are not meaningful, so the setting has no effect.
42+
2943
### JSON Schema
3044

3145
Add `$schema` to your config file for autocomplete and validation in VS Code and other editors:
@@ -121,6 +135,7 @@ Higher-tier models with longer cache windows benefit from a longer TTL. Setting
121135
| `keep_subagents` | `boolean` | `false` | Debug: keep the child sessions Magic Context spawns for its own subagents (historian, dreamer, sidekick, memory-migration) instead of deleting them on success, so their full transcript stays in the host session store for inspection. Kept sessions accumulate until cleared manually — leave `false` for normal use. |
122136
| `todowrite` | `object` | See below | **Pi only.** Controls Magic Context's built-in `todowrite` tool and persistent task overlay. OpenCode has its own built-in `todowrite`, so this setting has no effect there. |
123137
| `sqlite` | `object` | See below | Per-connection SQLite tuning for Magic Context's own `context.db`. |
138+
| `storage.enforce_private_permissions` | `boolean` | `true` | User-config-only. Keep owner-only `0700` directories and `0600` files. Set `false` only for an externally managed trusted-group deployment; Magic Context will never re-tighten storage permissions. |
124139

125140
### `fail_closed_blocking`
126141

assets/magic-context.schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,20 @@
12591259
}
12601260
}
12611261
},
1262+
"storage": {
1263+
"default": {
1264+
"enforce_private_permissions": true
1265+
},
1266+
"description": "Storage permission policy. The default keeps session content and memories owner-private. Disabling enforcement is for trusted shared-group storage managed externally; every group member able to read the storage can read all stored session content and memories.",
1267+
"type": "object",
1268+
"properties": {
1269+
"enforce_private_permissions": {
1270+
"default": true,
1271+
"description": "When true (default), Magic Context creates and re-tightens its storage directories to owner-only 0700 and storage files to owner-only 0600. Set false only for a deliberate trusted-group deployment whose operator manages directory, database, WAL/SHM, cache, and RPC file permissions externally; Magic Context then never chmods or supplies restrictive creation modes. USER-LEVEL ONLY — ignored in project config for security. On Windows, POSIX chmod modes are already meaningless, so this setting is a no-op.",
1272+
"type": "boolean"
1273+
}
1274+
}
1275+
},
12621276
"embedding": {
12631277
"description": "Embedding provider configuration",
12641278
"type": "object",

packages/docs/src/content/docs/reference/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Add the schema line for editor validation and autocomplete:
2323
```
2424

2525
:::note
26-
Project-level configs cannot use `{env:VAR}` / `{file:path}` expansion. A cloned repository also cannot set `sqlite.*`, hidden-agent prompts/permissions, `historian.model`, or `historian.fallback_models`. Project `execute_threshold_percentage` / `execute_threshold_tokens` may only RAISE thresholds relative to the user's effective settings (a repo may delay compaction, not make it happen earlier). Dreamer model/schedule/task tuning and `memory.enabled` remain allowed project overrides.
26+
Project-level configs cannot use `{env:VAR}` / `{file:path}` expansion. A cloned repository also cannot set `sqlite.*`, `storage.enforce_private_permissions`, hidden-agent prompts/permissions, `historian.model`, or `historian.fallback_models`. Project `execute_threshold_percentage` / `execute_threshold_tokens` may only RAISE thresholds relative to the user's effective settings (a repo may delay compaction, not make it happen earlier). Dreamer model/schedule/task tuning and `memory.enabled` remain allowed project overrides.
2727
:::
2828

2929
## Top-level switches
@@ -250,6 +250,8 @@ Behavior tuning most installs never need to touch.
250250
| `sqlite` | object || SQLite connection tuning for Magic Context's own context.db. These are per-connection PRAGMAs applied at open; they do not change the schema or what is stored. |
251251
| `sqlite.cache_size_mb` | number (2–2048) | `64` | Page-cache size in MiB per connection (PRAGMA cache_size). Larger keeps more hot pages resident, cutting re-reads on repeated full-table scans. (min 2, max 2048, default 64) |
252252
| `sqlite.mmap_size_mb` | number (0–8192) | `0` | Memory-mapped I/O size in MiB (PRAGMA mmap_size). 0 disables mmap (SQLite default). Raising it can cut read overhead on large DBs at the cost of address space. (min 0, max 8192, default 0) |
253+
| `storage` | object || Storage permission policy. The default keeps session content and memories owner-private. Disabling enforcement is for trusted shared-group storage managed externally; every group member able to read the storage can read all stored session content and memories. |
254+
| `storage.enforce_private_permissions` | boolean | `true` | When true (default), Magic Context creates and re-tightens its storage directories to owner-only 0700 and storage files to owner-only 0600. Set false only for a deliberate trusted-group deployment whose operator manages directory, database, WAL/SHM, cache, and RPC file permissions externally; Magic Context then never chmods or supplies restrictive creation modes. USER-LEVEL ONLY — ignored in project config for security. On Windows, POSIX chmod modes are already meaningless, so this setting is a no-op. |
253255

254256
## Other
255257

packages/pi-plugin/src/config/index.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,29 @@ describe("loadPiConfig", () => {
168168
expect(result.loadedFromPaths).toEqual([userPath]);
169169
});
170170

171+
it("honors user storage permissions while ignoring a project-tier override", () => {
172+
const cwd = makeTempRoot("mc-pi-cwd-");
173+
const home = makeTempRoot("mc-pi-home-");
174+
withHome(home);
175+
writeUserConfig(
176+
home,
177+
JSON.stringify({ storage: { enforce_private_permissions: false } }),
178+
);
179+
writeProjectConfig(
180+
cwd,
181+
JSON.stringify({
182+
storage: { enforce_private_permissions: true, futureSibling: 1 },
183+
}),
184+
);
185+
186+
const result = loadPiConfig({ cwd });
187+
188+
expect(result.config.storage.enforce_private_permissions).toBe(false);
189+
expect(result.warnings.join("\n")).toContain(
190+
"storage.enforce_private_permissions",
191+
);
192+
});
193+
171194
it("merges user then project with project overrides winning", () => {
172195
const cwd = makeTempRoot("mc-pi-cwd-");
173196
const home = makeTempRoot("mc-pi-home-");

packages/pi-plugin/src/index.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import { setKeepSubagents } from "@magic-context/core/shared/keep-subagents";
9393
import { log } from "@magic-context/core/shared/logger";
9494
import { isSaneLimit } from "@magic-context/core/shared/models-dev-cache";
9595
import { resolveFallbackChain } from "@magic-context/core/shared/resolve-fallbacks";
96+
import { setStoragePrivatePermissionEnforcement } from "@magic-context/core/shared/storage-permissions";
9697

9798
import { handlePiCloneSessionStart } from "./clone-inheritance";
9899
import {
@@ -721,6 +722,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
721722
markPiMagicContextActive();
722723
beginBootQuietPeriod();
723724

725+
// Resolve the user-tier storage policy before opening the shared database.
726+
// Project config cannot alter it, so every project in this process shares the
727+
// operator's chosen owner-private or externally managed permission policy.
728+
const bootProjectDir = process.cwd();
729+
ensureConfigLocationsMigrated(bootProjectDir);
730+
const bootConfig = loadPiConfig({ cwd: bootProjectDir });
731+
setStoragePrivatePermissionEnforcement(
732+
bootConfig.config.storage.enforce_private_permissions,
733+
);
734+
setSqlitePragmaConfig({
735+
cacheSizeMb: bootConfig.config.sqlite.cache_size_mb,
736+
mmapSizeMb: bootConfig.config.sqlite.mmap_size_mb,
737+
});
738+
724739
const storageDir = getMagicContextStorageDir();
725740
const dbPath = join(storageDir, "context.db");
726741

@@ -894,10 +909,13 @@ async function startPiMagicContextRuntime(
894909
dedupe: true,
895910
});
896911

897-
// Pi opens the shared DB before config is available (above), so apply the
898-
// configured SQLite tuning to the already-open connection now. cache_size /
899-
// mmap_size take effect live; future opens in this process pick them up via
912+
// Reapply boot-resolved storage and SQLite settings in case config changed
913+
// between the initial open and runtime registration. cache_size / mmap_size
914+
// take effect live; future opens in this process pick them up via
900915
// setSqlitePragmaConfig.
916+
setStoragePrivatePermissionEnforcement(
917+
config.storage.enforce_private_permissions,
918+
);
901919
setSqlitePragmaConfig({
902920
cacheSizeMb: config.sqlite.cache_size_mb,
903921
mmapSizeMb: config.sqlite.mmap_size_mb,

packages/pi-plugin/src/subagent-entry.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import type { ContextDatabase } from "@magic-context/core/features/magic-context
5656
import { openDatabase } from "@magic-context/core/features/magic-context/storage-db";
5757
import { setHarness } from "@magic-context/core/shared/harness";
5858
import { log } from "@magic-context/core/shared/logger";
59+
import { setStoragePrivatePermissionEnforcement } from "@magic-context/core/shared/storage-permissions";
5960
import { loadPiConfig } from "./config";
6061
import { ensureProjectRegisteredFromPiDirectory } from "./embedding-bootstrap";
6162
import { registerMagicContextTools } from "./tools";
@@ -81,20 +82,20 @@ export default function magicContextSubagentExtension(pi: ExtensionAPI): void {
8182

8283
pi.on("session_start", async () => {
8384
try {
85+
// Load shared config before opening storage so a trusted-group deployment
86+
// never has its externally managed permissions re-tightened by a child.
87+
const directory = process.cwd();
88+
const { config: cfg } = loadPiConfig({ cwd: directory });
89+
setStoragePrivatePermissionEnforcement(
90+
cfg.storage.enforce_private_permissions,
91+
);
8492
const db = openDatabase();
8593
if (!db) {
8694
throw new Error(
8795
"storage open failed; refusing to start without Magic Context tools",
8896
);
8997
}
9098
openedDb = db;
91-
92-
// Load shared config so embedding settings + memory enabled
93-
// flag match the parent's runtime. Subagent doesn't honor
94-
// historian/dreamer/sidekick blocks at all (those are
95-
// parent-only concerns).
96-
const directory = process.cwd();
97-
const { config: cfg } = loadPiConfig({ cwd: directory });
9899
await ensureProjectRegisteredFromPiDirectory(directory, db);
99100
const dreamerActionsEnabled =
100101
pi.getFlag(SUBAGENT_DREAMER_ACTIONS_FLAG) === true;

packages/plugin/scripts/build-config-docs.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ const SECTION_ORDER: Array<{ keys: string[]; title: string; intro: string }> = [
142142
"caveman_text_compression",
143143
"system_prompt_injection",
144144
"sqlite",
145+
"storage",
145146
],
146147
title: "Advanced",
147148
intro: "Behavior tuning most installs never need to touch.",
@@ -245,7 +246,7 @@ Add the schema line for editor validation and autocomplete:
245246
\`\`\`
246247
247248
:::note
248-
Project-level configs cannot use \`{env:VAR}\` / \`{file:path}\` expansion. A cloned repository also cannot set \`sqlite.*\`, hidden-agent prompts/permissions, \`historian.model\`, or \`historian.fallback_models\`. Project \`execute_threshold_percentage\` / \`execute_threshold_tokens\` may only RAISE thresholds relative to the user's effective settings (a repo may delay compaction, not make it happen earlier). Dreamer model/schedule/task tuning and \`memory.enabled\` remain allowed project overrides.
249+
Project-level configs cannot use \`{env:VAR}\` / \`{file:path}\` expansion. A cloned repository also cannot set \`sqlite.*\`, \`storage.enforce_private_permissions\`, hidden-agent prompts/permissions, \`historian.model\`, or \`historian.fallback_models\`. Project \`execute_threshold_percentage\` / \`execute_threshold_tokens\` may only RAISE thresholds relative to the user's effective settings (a repo may delay compaction, not make it happen earlier). Dreamer model/schedule/task tuning and \`memory.enabled\` remain allowed project overrides.
249250
:::
250251
251252
${sections.join("\n\n")}

packages/plugin/src/config/index.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,16 @@ describe("loadPluginConfig — secret redaction", () => {
355355
expect(result.embedding.endpoint).toBe("https://embeddings.example/v1");
356356
});
357357

358+
it("honors user storage permissions while ignoring a project-tier override", () => {
359+
const result = loadWithUserAndProjectConfig(
360+
JSON.stringify({ storage: { enforce_private_permissions: false } }),
361+
JSON.stringify({ storage: { enforce_private_permissions: true, futureSibling: 1 } }),
362+
);
363+
364+
expect(result.storage.enforce_private_permissions).toBe(false);
365+
expect(result.configWarnings?.join("\n")).toContain("storage.enforce_private_permissions");
366+
});
367+
358368
it("ignores embedding destination fields from untrusted project config", () => {
359369
const userConfig = JSON.stringify({
360370
embedding: {

packages/plugin/src/config/project-security.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,28 @@ describe("stripUnsafeProjectConfigFields", () => {
5959
expect(warnings.some((w) => w.includes("sqlite"))).toBe(true);
6060
});
6161

62+
it("strips storage.enforce_private_permissions from project config (only-key case)", () => {
63+
const raw: Record<string, unknown> = {
64+
storage: { enforce_private_permissions: false },
65+
};
66+
67+
const warnings = stripUnsafeProjectConfigFields(raw);
68+
69+
expect(raw.storage).toEqual({});
70+
expect(warnings.some((w) => w.includes("storage.enforce_private_permissions"))).toBe(true);
71+
});
72+
73+
it("strips storage.enforce_private_permissions but keeps a sibling key", () => {
74+
const raw: Record<string, unknown> = {
75+
storage: { enforce_private_permissions: false, futureSibling: 1 },
76+
};
77+
78+
const warnings = stripUnsafeProjectConfigFields(raw);
79+
80+
expect(raw.storage).toEqual({ futureSibling: 1 });
81+
expect(warnings.some((w) => w.includes("storage.enforce_private_permissions"))).toBe(true);
82+
});
83+
6284
it("strips Pi subagent extension allowlists from project config", () => {
6385
const raw: Record<string, unknown> = {
6486
pi: { subagent_extensions: ["./repo-controlled-extension.ts"] },

packages/plugin/src/config/project-security.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ function makeProjectThresholdWarning(field: string, reason: string): string {
198198
* process). A cloned repo could set a huge value to exhaust host memory /
199199
* address space — a resource-exhaustion vector with no legitimate per-repo
200200
* use. Honor user-level config only.
201+
* - `storage.enforce_private_permissions` — changing a shared store from
202+
* owner-private to group-readable changes every session and memory's local
203+
* confidentiality. Only the machine operator's user config may opt into an
204+
* externally managed trusted-group deployment.
201205
* - `embedding.endpoint` / `embedding.provider` — a repo must not choose
202206
* where private memory/search/commit text is embedded. User-level config is
203207
* the trust boundary for embedding destinations.
@@ -256,6 +260,17 @@ export function stripUnsafeProjectConfigFields(projectRaw: Record<string, unknow
256260
);
257261
}
258262

263+
// storage.enforce_private_permissions is USER-tier only because disabling it
264+
// changes the confidentiality of the process-global shared store. Field-scoped
265+
// stripping preserves future project-tier storage settings in the same block.
266+
const storage = projectRaw.storage;
267+
if (isPlainObject(storage) && "enforce_private_permissions" in storage) {
268+
delete storage.enforce_private_permissions;
269+
warnings.push(
270+
"Ignoring storage.enforce_private_permissions from project config (security: only user-level config may opt into externally managed shared storage permissions).",
271+
);
272+
}
273+
259274
const pi = projectRaw.pi;
260275
if (isPlainObject(pi) && "subagent_extensions" in pi) {
261276
delete pi.subagent_extensions;

0 commit comments

Comments
 (0)