@commonlyai/cli 0.1.180 → 0.1.181

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.
@@ -47,6 +47,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js';
47
47
  import {
48
48
  isLegacySandboxTrust,
49
49
  normalizeSandboxTrust,
50
+ refuseWorkspaceTrust,
50
51
  validateEnvironmentSpec,
51
52
  } from '../environment.js';
52
53
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
@@ -651,6 +652,7 @@ export default {
651
652
  async spawn(prompt, rawCtx = {}) {
652
653
  const seatModel = requireSeatModel(rawCtx.environment, 'opencode');
653
654
  const sandbox = normalizeSandboxTrust(rawCtx.environment?.sandbox);
655
+ refuseWorkspaceTrust(sandbox, 'opencode');
654
656
  const ctx = rawCtx.environment
655
657
  ? { ...rawCtx, environment: { ...rawCtx.environment, sandbox } }
656
658
  : rawCtx;
@@ -44,7 +44,7 @@ import {
44
44
  spawnCredentials,
45
45
  } from '../upstream-refusal.js';
46
46
  import { buildMemoryPreamble } from '../memory-bridge.js';
47
- import { effectiveSandboxTrust } from '../environment.js';
47
+ import { effectiveSandboxTrust, refuseWorkspaceTrust } from '../environment.js';
48
48
  import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
49
49
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
50
50
  import { prepareMcpSpawn } from '../mcp-home.js';
@@ -306,6 +306,7 @@ export const sessionExists = async (sessionDir, sessionId) => {
306
306
  */
307
307
  export const assertNoSandboxDeclared = (environment = {}) => {
308
308
  const sandbox = environment?.sandbox || {};
309
+ refuseWorkspaceTrust(sandbox, 'pi');
309
310
  if (effectiveSandboxTrust(sandbox.trust) === 'public') {
310
311
  throw new Error('pi adapter: public-trust seats are not supported — pi has no enforced sandbox; do not attach a pi seat to a stranger-readable pod');
311
312
  }
@@ -9,7 +9,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js';
9
9
  import { deliverSeatCredential } from '../mcp-credential-delivery.js';
10
10
  import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
11
11
  import { writeCredentialFile } from '../credential-file.js';
12
- import { normalizeSandboxTrust, validateEnvironmentSpec } from '../environment.js';
12
+ import { normalizeSandboxTrust, refuseWorkspaceTrust, validateEnvironmentSpec } from '../environment.js';
13
13
  import { resolvePublicSandboxMode } from '../sandbox/mode.js';
14
14
  import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
15
15
  import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
@@ -234,6 +234,7 @@ export default {
234
234
  async spawn(prompt, ctx = {}) {
235
235
  const seatModel = requireSeatModel(ctx.environment, 'qwen', true);
236
236
  const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
237
+ refuseWorkspaceTrust(sandbox, 'qwen');
237
238
  const isPublic = sandbox?.trust === 'public';
238
239
  const mode = isPublic ? resolvePublicSandboxMode(sandbox) : null;
239
240
  if (isPublic && !['workspace', 'read-only', 'bwrap'].includes(mode)) {
@@ -151,13 +151,15 @@ export const withDefaultSandbox = (environment) => {
151
151
  // gate accepts — benign (same effective trust) but not a mirror, and the
152
152
  // comment would be false. The ORIGINAL block is what gets returned below.
153
153
  const sandbox = normalizeSandboxTrust(environment?.sandbox);
154
- // An ENFORCED declaration is one the adapters act on: a public trust (whose
155
- // mode they resolve, and refuse to spawn without) or an explicit non-'none'
154
+ // An ENFORCED declaration is one the adapters act on: a public or workspace
155
+ // trust (whose mode they resolve, and refuse to spawn without — every adapter
156
+ // but claude refuses `workspace` outright, TASK-294) or an explicit non-'none'
156
157
  // mode. `mode: 'none'`, an empty block and a missing one are all the same
157
158
  // thing to a spawn — no confinement — so all three are replaced here.
158
159
  const enforced = sandbox !== null && typeof sandbox === 'object'
159
160
  && sandbox.mode !== 'none'
160
- && (sandbox.trust === 'public' || typeof sandbox.mode === 'string');
161
+ && (sandbox.trust === 'public' || sandbox.trust === 'workspace'
162
+ || typeof sandbox.mode === 'string');
161
163
  if (enforced) return environment;
162
164
  return { ...(environment || {}), sandbox: defaultSeatSandbox() };
163
165
  };
@@ -47,7 +47,14 @@ const ALLOWED_TOP_KEYS = new Set([
47
47
  const ALLOWED_SANDBOX_MODES = new Set([
48
48
  'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
49
49
  ]);
50
- const ALLOWED_SANDBOX_TRUST = new Set(['public']);
50
+ // `workspace` is the middle trust the comment below waits for (TASK-294): a seat
51
+ // on a pod nobody outside it can read, allowed to read its start directory and
52
+ // run read-only git there. Only the claude adapter reads it; every other adapter
53
+ // refuses it before spawning, so it never degrades into a bare spawn.
54
+ const ALLOWED_SANDBOX_TRUST = new Set(['public', 'workspace']);
55
+ // The opt-ins a `workspace` seat can carry. Each one widens a single thing:
56
+ // `write` adds Edit/Write/Bash and a writable workspace; `web` adds WebFetch.
57
+ export const WORKSPACE_SANDBOX_ALLOW = Object.freeze(['write', 'web']);
51
58
  const ALLOWED_NETWORK_POLICIES = new Set(['unrestricted', 'restricted']);
52
59
  const INLINE_PROVIDER_CREDENTIAL_FIELD = /(?:key|token|secret|password|authorization|header|credential|auth)/i;
53
60
 
@@ -72,7 +79,8 @@ const findInlineProviderCredentialField = (value, path) => {
72
79
  // above, and a record that already carries it is resolved TOWARD confinement,
73
80
  // never toward the bare spawn (Wren 69585): `internal` is read as `public`, so
74
81
  // such a seat is confined where it can be and refuses to derive where it
75
- // cannot. A real middle trust arrives as its own adapter that reads it.
82
+ // cannot. A real middle trust arrives as its own adapter that reads it — and
83
+ // `workspace` (above) is that trust, which is why it is NOT a legacy alias.
76
84
  export const LEGACY_SANDBOX_TRUST = Object.freeze({ internal: 'public' });
77
85
  export const isLegacySandboxTrust = (sandbox) => (
78
86
  typeof sandbox?.trust === 'string'
@@ -100,6 +108,21 @@ export const effectiveSandboxTrust = (trust) => (
100
108
  : trust
101
109
  );
102
110
 
111
+ /**
112
+ * Throw when an adapter that does not implement the `workspace` tier is handed
113
+ * one (TASK-294). Only claude reads it. Every other adapter branches on
114
+ * `trust === 'public'`, so an unread `workspace` would fall into its
115
+ * non-public branch — the same silent widening `internal` was (Vera 69592).
116
+ * Called at each adapter's spawn entry, before any other sandbox read.
117
+ */
118
+ export const refuseWorkspaceTrust = (sandbox, adapterLabel) => {
119
+ if (sandbox?.trust !== 'workspace') return;
120
+ throw new Error(
121
+ `${adapterLabel} adapter: sandbox.trust=workspace is claude-only for now. `
122
+ + "Declare sandbox.trust 'public' for this seat, or attach it with the claude adapter.",
123
+ );
124
+ };
125
+
103
126
  const expandHome = (p) => {
104
127
  if (!p || typeof p !== 'string') return p;
105
128
  if (p === '~') return homedir();
@@ -294,18 +317,33 @@ export const validateEnvironmentSpec = (spec) => {
294
317
  errors.push('sandbox must be an object');
295
318
  } else {
296
319
  const {
297
- mode, trust, network, filesystem,
320
+ mode, trust, network, filesystem, allow,
298
321
  } = spec.sandbox;
299
322
  if (mode !== undefined && !ALLOWED_SANDBOX_MODES.has(mode)) {
300
323
  errors.push(`sandbox.mode must be one of: ${[...ALLOWED_SANDBOX_MODES].join(', ')}`);
301
324
  }
302
325
  if (trust !== undefined && !ALLOWED_SANDBOX_TRUST.has(trust)) {
303
326
  errors.push(
304
- "sandbox.trust must be 'public' — it marks a seat anyone in the pod can "
305
- + 'talk to; omit it for your own seat '
327
+ "sandbox.trust must be 'public' or 'workspace' — 'public' marks a seat "
328
+ + "anyone in the pod can talk to, 'workspace' a seat on a private pod that "
329
+ + 'reads its start directory; omit it for an unconfined seat of your own '
306
330
  + `(got ${JSON.stringify(trust)})`,
307
331
  );
308
332
  }
333
+ if (allow !== undefined) {
334
+ if (!Array.isArray(allow) || !allow.every((a) => WORKSPACE_SANDBOX_ALLOW.includes(a))) {
335
+ errors.push(`sandbox.allow must be an array of: ${WORKSPACE_SANDBOX_ALLOW.join(', ')}`);
336
+ } else if (trust !== 'workspace') {
337
+ // An opt-in widens the workspace tier and nothing else. On a public
338
+ // seat it would read as a way to hand strangers Bash.
339
+ errors.push(
340
+ "sandbox.allow applies only to trust 'workspace' "
341
+ + `(got trust ${JSON.stringify(trust)})`,
342
+ );
343
+ } else if (allow.includes('write') && mode === 'read-only') {
344
+ errors.push("sandbox.allow 'write' cannot be combined with sandbox.mode 'read-only'");
345
+ }
346
+ }
309
347
  if (network !== undefined) {
310
348
  if (typeof network !== 'object' || network === null) {
311
349
  errors.push('sandbox.network must be an object');
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The `workspace_git` MCP server: one tool, `git`, for a workspace-trust seat
3
+ * (TASK-294). The claude adapter spawns it as
4
+ *
5
+ * node git-readonly-server.js --root <workspace> --git <binary>
6
+ *
7
+ * and the model sees it as `mcp__workspace_git__git`. It speaks the MCP stdio
8
+ * transport (newline-delimited JSON-RPC 2.0) and hands every call to
9
+ * runReadonlyGit, which refuses anything that is not read-only and runs the
10
+ * rest with execFile in the workspace.
11
+ */
12
+ import { realpathSync, statSync } from 'fs';
13
+ import { isAbsolute } from 'path';
14
+ import { createInterface } from 'readline';
15
+ import { fileURLToPath } from 'url';
16
+ import { READONLY_GIT_SUBCOMMANDS, runReadonlyGit } from './git-readonly.js';
17
+
18
+ export const SERVER_NAME = 'workspace_git';
19
+ export const TOOL_NAME = 'git';
20
+ const SERVER_VERSION = '1.0.0';
21
+
22
+ // The tool surface is the same in each of these, so the server answers in the
23
+ // version the client asked for when it is one of them.
24
+ const SUPPORTED_PROTOCOL_VERSIONS = Object.freeze(['2024-11-05', '2025-03-26', '2025-06-18']);
25
+
26
+ export const GIT_TOOL = Object.freeze({
27
+ name: TOOL_NAME,
28
+ description: `Run read-only git in your workspace: ${READONLY_GIT_SUBCOMMANDS.join(', ')}. `
29
+ + 'Pass the subcommand and its arguments separately, one argument per array entry, '
30
+ + "e.g. { subcommand: 'log', args: ['-n', '5', '--oneline'] }. "
31
+ + 'Options that write a file, run a program, or read outside the workspace are refused with the reason, '
32
+ + 'and paths must stay inside the workspace. For log, diff and show, put paths after \'--\' '
33
+ + "(e.g. ['-p', '--', 'src/app.js']). .env files are left out of every result. "
34
+ + 'This is the only git available to you.',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ subcommand: { type: 'string', enum: [...READONLY_GIT_SUBCOMMANDS] },
39
+ args: {
40
+ type: 'array',
41
+ items: { type: 'string' },
42
+ description: 'Arguments after the subcommand, one per entry.',
43
+ },
44
+ },
45
+ required: ['subcommand'],
46
+ additionalProperties: false,
47
+ },
48
+ });
49
+
50
+ const reply = (id, result) => ({ jsonrpc: '2.0', id, result });
51
+ const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
52
+
53
+ /**
54
+ * A handler for one parsed JSON-RPC message. Resolves the response, or null
55
+ * for a notification (a message with no id), which gets none.
56
+ */
57
+ export const createGitServerHandler = ({
58
+ root, git = 'git', baseEnv = process.env, run = runReadonlyGit,
59
+ }) => async (message) => {
60
+ if (!message || typeof message !== 'object' || Array.isArray(message) || typeof message.method !== 'string') {
61
+ return fail(message?.id ?? null, -32600, 'Invalid Request');
62
+ }
63
+ const { id, method, params } = message;
64
+ if (id === undefined) return null;
65
+ if (method === 'initialize') {
66
+ const asked = params?.protocolVersion;
67
+ return reply(id, {
68
+ protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(asked) ? asked : SUPPORTED_PROTOCOL_VERSIONS[0],
69
+ capabilities: { tools: {} },
70
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
71
+ });
72
+ }
73
+ if (method === 'ping') return reply(id, {});
74
+ if (method === 'tools/list') return reply(id, { tools: [GIT_TOOL] });
75
+ if (method === 'tools/call') {
76
+ if (params?.name !== TOOL_NAME) return fail(id, -32602, `Unknown tool: ${params?.name}`);
77
+ const { subcommand, args = [] } = params.arguments || {};
78
+ const { ok, text } = await run({
79
+ subcommand, args, root, git, baseEnv,
80
+ });
81
+ return reply(id, { content: [{ type: 'text', text }], isError: !ok });
82
+ }
83
+ return fail(id, -32601, `Method not found: ${method}`);
84
+ };
85
+
86
+ /** `--root <dir> --git <binary>`; the root must be an existing directory. */
87
+ export const parseServerArgs = (argv) => {
88
+ const value = (flag) => {
89
+ const at = argv.indexOf(flag);
90
+ return at === -1 ? undefined : argv[at + 1];
91
+ };
92
+ const root = value('--root');
93
+ if (typeof root !== 'string' || !isAbsolute(root)) throw new Error('--root <absolute workspace path> is required');
94
+ const real = realpathSync(root);
95
+ if (!statSync(real).isDirectory()) throw new Error(`--root ${root} is not a directory`);
96
+ return { root: real, git: value('--git') || 'git' };
97
+ };
98
+
99
+ export const serve = ({
100
+ root, git, input = process.stdin, output = process.stdout, baseEnv = process.env,
101
+ }) => {
102
+ const handle = createGitServerHandler({ root, git, baseEnv });
103
+ // A closed pipe on the client side is the client leaving, not an error.
104
+ output.on('error', () => {});
105
+ const lines = createInterface({ input, crlfDelay: Infinity });
106
+ lines.on('line', async (line) => {
107
+ if (!line.trim()) return;
108
+ let message;
109
+ try {
110
+ message = JSON.parse(line);
111
+ } catch {
112
+ output.write(`${JSON.stringify(fail(null, -32700, 'Parse error'))}\n`);
113
+ return;
114
+ }
115
+ const response = await handle(message);
116
+ if (response) output.write(`${JSON.stringify(response)}\n`);
117
+ });
118
+ return lines;
119
+ };
120
+
121
+ const runAsMain = () => {
122
+ try {
123
+ return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
124
+ } catch {
125
+ return false;
126
+ }
127
+ };
128
+
129
+ if (runAsMain()) {
130
+ let options;
131
+ try {
132
+ options = parseServerArgs(process.argv.slice(2));
133
+ } catch (err) {
134
+ process.stderr.write(`${SERVER_NAME}: ${err.message}\n`);
135
+ process.exit(2);
136
+ }
137
+ serve(options);
138
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Read-only git for a `workspace`-trust seat (TASK-294).
3
+ *
4
+ * A workspace seat may look at the repository it was started in (status, log,
5
+ * diff, show, blame) and nothing more: no git call it can make may write a
6
+ * file, run a program, or read outside its workspace. A Bash permission prefix
7
+ * is not a boundary this relies on, so Bash stays denied to workspace seats
8
+ * and git never goes through it. Git is one MCP tool (git-readonly-server.js)
9
+ * whose argv this module builds and runs with execFile, with no shell in
10
+ * between.
11
+ *
12
+ * What holds the line:
13
+ * 1. The subcommand is one of five builtins. A builtin name never resolves to
14
+ * an alias, and the caller's arguments go AFTER it, where git's top-level
15
+ * options (`-c`, `-C`, `--git-dir`, `--exec-path`, …) are not parsed.
16
+ * 2. The arguments are checked against the spellings that write, execute, or
17
+ * read a file the caller names (DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS,
18
+ * DENIED_SHORT_OPTIONS_BY_SUBCOMMAND), including git's accepted
19
+ * abbreviations of them (`--outp=`), and against
20
+ * paths that leave the workspace.
21
+ * 3. diff, log and show run with `--no-ext-diff --no-textconv`, and blame with
22
+ * `--no-textconv`, so no external diff or conversion program configured for
23
+ * the repository runs, and the spellings that would turn them back on are
24
+ * refused. log and show also run with `--no-show-signature`, and
25
+ * `format.pretty` is forced to `medium`, so nothing runs gpg:
26
+ * `--show-signature`, a `%G` placeholder and a configured format alias
27
+ * (which can hold one) are refused. `core.fsmonitor` is forced off for the
28
+ * same reason: `git status` runs it. What still runs is a clean filter the
29
+ * repository's own config defines, when status, diff or blame read a
30
+ * working-tree file: without it they report unchanged files as changed. It
31
+ * runs inside the sandbox below, and the seat cannot define one. A
32
+ * submodule's git runs as a child process that gets none of these flags, so
33
+ * `--submodule=diff` is refused, and the settings that would start one are
34
+ * forced through `-c`, which does reach it (TOP_LEVEL).
35
+ * 4. The environment is rebuilt from an allowlist. No inherited GIT_* variable
36
+ * survives, system and global config are off, optional locks are off (so
37
+ * status never rewrites the index), and GIT_CEILING_DIRECTORIES stops
38
+ * repository discovery at the workspace. The only repository git can open
39
+ * is one rooted there, so `show HEAD:<path>` cannot reach a file above it.
40
+ * No git it runs contacts a remote: lazy fetching is off, so a partial
41
+ * clone reports an object it lacks as missing instead of fetching it, and
42
+ * no transport is allowed, which covers git older than 2.44, where
43
+ * GIT_NO_LAZY_FETCH does nothing.
44
+ * 5. Outside this module, the seat's sandbox (Seatbelt or bwrap) mounts the
45
+ * workspace read-only.
46
+ * 6. A committed `.env` or `.env.*` stays as far out of reach as the file
47
+ * itself, which the sandbox denies at any depth. An argument naming one
48
+ * (`.env`, `HEAD:.env`, `:0:sub/.env.local`, `-L1,5:.env`) is refused, and
49
+ * status, log, diff and show run with pathspecs excluding them, so no patch,
50
+ * pickaxe match or blob id comes from one whatever path or glob the caller
51
+ * named. `--follow` takes exactly one path, so it is refused, and so is
52
+ * `--full-diff`, which lets pathspecs pick commits and then shows every
53
+ * file each one changed. A line range (log and show `-L<range>:<file>`)
54
+ * takes no pathspec, so it is taken only as the first argument, in one
55
+ * piece, where git cannot read it as another option's value, and its file
56
+ * is name-checked. What this cannot stop is `show <blob id>` with an id
57
+ * learned somewhere else.
58
+ */
59
+ import { execFile } from 'child_process';
60
+ import { realpathSync } from 'fs';
61
+ import {
62
+ dirname, isAbsolute, relative, resolve,
63
+ } from 'path';
64
+
65
+ export const READONLY_GIT_SUBCOMMANDS = Object.freeze(['status', 'log', 'diff', 'show', 'blame']);
66
+
67
+ // Long options refused in any position, with what each one does.
68
+ export const DENIED_LONG_OPTIONS = Object.freeze({
69
+ '--output': 'writes its result to a file',
70
+ '--ext-diff': 'runs an external diff program',
71
+ '--textconv': 'runs a configured conversion program',
72
+ '--no-index': 'compares files outside the repository',
73
+ '--contents': 'reads a file the caller names',
74
+ '--ignore-revs-file': 'reads a file the caller names',
75
+ '--git-dir': 'opens a different repository',
76
+ '--work-tree': 'opens a different working tree',
77
+ '--exec-path': 'runs git programs from another directory',
78
+ '--config-env': 'sets configuration',
79
+ '--paginate': 'runs a pager',
80
+ '--show-signature': 'runs gpg to check a signature',
81
+ '--follow': 'needs exactly one path, and every log here also carries the .env excludes',
82
+ '--full-diff': 'shows every file a picked commit changed, .env files included',
83
+ });
84
+
85
+ // Real options that are also prefixes of a denied one. Git takes an exact name
86
+ // over an abbreviation, so these never mean the denied option.
87
+ const EXACT_NAMES_THAT_PREFIX_A_DENIED_ONE = new Set(['--text', '--ignore-rev']);
88
+
89
+ // Single-letter options refused, with what each one does.
90
+ export const DENIED_SHORT_OPTIONS = Object.freeze({
91
+ c: 'sets configuration (as a top-level option)',
92
+ C: 'changes directory (as a top-level option); use --find-copies to detect copies',
93
+ O: 'reads a file the caller names',
94
+ });
95
+
96
+ // Letters refused for one subcommand only. log, diff and show read -S as the
97
+ // pickaxe string, but blame reads it as a file of revisions, and prints back a
98
+ // line it cannot parse ("bad graft data: <line>").
99
+ export const DENIED_SHORT_OPTIONS_BY_SUBCOMMAND = Object.freeze({
100
+ blame: Object.freeze({ S: 'reads a revisions file the caller names' }),
101
+ });
102
+
103
+ // Short options that carry their value in the same argument (`-n5`, `-U3`,
104
+ // `-Sneedle`, `-Gregex`, `-L1,5:file`, `-M50%`, `-B`). Git hands the rest of
105
+ // the argument to the option as its value, so nothing after the letter can be
106
+ // read as another option.
107
+ const VALUE_SHORT_OPTIONS = new Set(['n', 'U', 'S', 'G', 'L', 'M', 'B']);
108
+ const STATUS_UNTRACKED_MODES = new Set(['-uno', '-unormal', '-uall']);
109
+
110
+ // Ahead of the caller's arguments, so a configured program never runs.
111
+ const FORCED_OPTIONS = Object.freeze({
112
+ diff: ['--no-ext-diff', '--no-textconv'],
113
+ log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'],
114
+ show: ['--no-ext-diff', '--no-textconv', '--no-show-signature'],
115
+ status: [],
116
+ blame: ['--no-textconv'],
117
+ });
118
+ // `format.pretty` is the repository's default format for log and show, and a
119
+ // format can hold a %G placeholder; `medium` is git's own default. The last
120
+ // three reach the child git a submodule runs in, which FORCED_OPTIONS do not:
121
+ // `diff.submodule=diff` would run git diff inside each submodule under that
122
+ // submodule's own config, `status.submoduleSummary` runs git log there, and
123
+ // `log.showSignature` would make that log run gpg.
124
+ const TOP_LEVEL = Object.freeze([
125
+ '--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium',
126
+ '-c', 'diff.submodule=short', '-c', 'status.submoduleSummary=false', '-c', 'log.showSignature=false',
127
+ ]);
128
+
129
+ // A %G placeholder (%G?, %GG, %GS, %GK, %GF, %GP, %GT) runs gpg on each signed
130
+ // commit. A value with no %, no format: and no tformat: is looked up by
131
+ // prefix among the built-in formats and the repository's `pretty.<name>`
132
+ // aliases, and an alias can hold one, so only a built-in name is taken.
133
+ const FORMAT_ARGUMENT = /^--(?:pretty|format)=([\s\S]*)$/;
134
+ const BUILTIN_FORMATS = Object.freeze(['oneline', 'short', 'medium', 'full', 'fuller', 'reference', 'email', 'mboxrd', 'raw']);
135
+ const checkFormat = (arg) => {
136
+ const match = FORMAT_ARGUMENT.exec(arg);
137
+ if (!match) return null;
138
+ const value = match[1];
139
+ if (value.includes('%G')) return `${arg} is refused: a %G placeholder runs gpg to check a signature`;
140
+ const userFormat = value === '' || value.startsWith('format:') || value.startsWith('tformat:') || value.includes('%');
141
+ if (userFormat || BUILTIN_FORMATS.includes(value.toLowerCase())) return null;
142
+ return `${arg} is refused: name a built-in format (${BUILTIN_FORMATS.join(', ')}) or give format:<placeholders>`;
143
+ };
144
+
145
+ // Mirrors the names sandbox/seatbelt.js denies at any depth.
146
+ const ENV_FILE_SEGMENT = /^\.env(\..*)?$/;
147
+ const namesEnvFile = (arg) => arg.split(':')
148
+ .some((part) => part.split('/').some((segment) => ENV_FILE_SEGMENT.test(segment)));
149
+ const NAMES_ENV_FILE = 'it names a .env file, which the workspace keeps out of reach';
150
+ const ENV_EXCLUDES = Object.freeze([
151
+ ':(exclude,glob)**/.env', ':(exclude,glob)**/.env.*', ':(exclude,glob)**/.env/**', ':(exclude,glob)**/.env.*/**',
152
+ ]);
153
+ // blame takes one literal file, so the name check above covers it. A
154
+ // blob-to-blob diff (`diff A:f B:f`) takes no pathspec and is refused by git
155
+ // here; `diff A B -- f` is the same diff. A line range takes none either (see
156
+ // checkLineRange), so its file is name-checked instead.
157
+ const PATHSPEC_SUBCOMMANDS = new Set(['status', 'log', 'diff', 'show']);
158
+
159
+ // log and show trace a line range (-L<range>:<file>), and git refuses a
160
+ // pathspec with one, so that call runs without the .env excludes. Only a token
161
+ // git is sure to read as -L may turn them off: the first argument, with its
162
+ // range in it. After another argument, git can read it as that option's value;
163
+ // a bare -L takes the next argument as its range, which the name check may
164
+ // read as an option instead. status and diff take no -L at all.
165
+ const LINE_RANGE_SUBCOMMANDS = new Set(['log', 'show']);
166
+ const checkLineRange = (arg, index, subcommand) => {
167
+ if (!LINE_RANGE_SUBCOMMANDS.has(subcommand)) return `-L is refused for ${subcommand}: only log and show trace a line range`;
168
+ if (index > 0 || arg.length === 2) {
169
+ return `${arg} is refused: give one line range, as the first argument and in one piece (-L<start>,<end>:<file>), so git cannot read it as another option's value`;
170
+ }
171
+ return null;
172
+ };
173
+
174
+ const MAX_ARGS = 64;
175
+ const MAX_ARG_LENGTH = 4096;
176
+ export const MAX_OUTPUT_CHARS = 50 * 1024;
177
+ const MAX_BUFFER_BYTES = 4 * 1024 * 1024;
178
+ const DEFAULT_TIMEOUT_MS = 30 * 1000;
179
+
180
+ const refuse = (reason) => ({ ok: false, reason });
181
+
182
+ const realOrResolved = (p) => {
183
+ try {
184
+ return realpathSync(p);
185
+ } catch {
186
+ return resolve(p);
187
+ }
188
+ };
189
+
190
+ const escapesRoot = (root, arg) => {
191
+ const rel = relative(root, resolve(root, arg));
192
+ return rel === '..' || rel.startsWith('../') || isAbsolute(rel);
193
+ };
194
+
195
+ const checkLongOption = (arg) => {
196
+ const name = arg.split('=')[0];
197
+ if (arg.endsWith('=diff') && name.length >= 3 && '--submodule'.startsWith(name)) {
198
+ return `${arg} is refused: it runs git diff inside each submodule, where the forced options don't reach; --submodule=log lists the same commits`;
199
+ }
200
+ if (Object.prototype.hasOwnProperty.call(DENIED_LONG_OPTIONS, name)) {
201
+ return `${name} is refused: it ${DENIED_LONG_OPTIONS[name]}`;
202
+ }
203
+ if (name.length >= 3 && !EXACT_NAMES_THAT_PREFIX_A_DENIED_ONE.has(name)) {
204
+ const denied = Object.keys(DENIED_LONG_OPTIONS).find((option) => option.startsWith(name));
205
+ if (denied) return `${name} is refused: git reads it as ${denied}, which ${DENIED_LONG_OPTIONS[denied]}`;
206
+ }
207
+ return null;
208
+ };
209
+
210
+ const checkShortOption = (arg, subcommand) => {
211
+ if (/^-\d+$/.test(arg)) return null;
212
+ const letter = arg[1];
213
+ if (Object.prototype.hasOwnProperty.call(DENIED_SHORT_OPTIONS, letter)) {
214
+ return `-${letter} is refused: it ${DENIED_SHORT_OPTIONS[letter]}`;
215
+ }
216
+ const deniedHere = DENIED_SHORT_OPTIONS_BY_SUBCOMMAND[subcommand] || {};
217
+ if (Object.prototype.hasOwnProperty.call(deniedHere, letter)) {
218
+ return `-${letter} is refused for ${subcommand}: it ${deniedHere[letter]}`;
219
+ }
220
+ if (arg.length === 2) return null;
221
+ if (VALUE_SHORT_OPTIONS.has(letter)) return null;
222
+ if (subcommand === 'status' && STATUS_UNTRACKED_MODES.has(arg)) return null;
223
+ return `${arg} is refused: pass each short option as its own argument`;
224
+ };
225
+
226
+ /**
227
+ * The argv for one read-only git call, or the reason it is refused.
228
+ * `root` is the workspace; the caller's paths are checked against it.
229
+ */
230
+ export const readonlyGitArgv = ({ subcommand, args = [], root }) => {
231
+ if (!READONLY_GIT_SUBCOMMANDS.includes(subcommand)) {
232
+ return refuse(`git ${subcommand} is refused: only ${READONLY_GIT_SUBCOMMANDS.join(', ')} are available`);
233
+ }
234
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
235
+ return refuse('args must be an array of strings');
236
+ }
237
+ if (args.length > MAX_ARGS) return refuse(`at most ${MAX_ARGS} arguments`);
238
+ if (typeof root !== 'string' || !isAbsolute(root)) return refuse('the workspace is not an absolute path');
239
+ let pathsOnly = false;
240
+ let lineRange = false;
241
+ for (const [index, arg] of args.entries()) {
242
+ if (arg.length > MAX_ARG_LENGTH || arg.includes('\0')) return refuse('an argument is too long or contains NUL');
243
+ if (pathsOnly || arg === '-' || !arg.startsWith('-')) {
244
+ if (escapesRoot(root, arg)) return refuse(`${arg} is refused: it is outside the workspace`);
245
+ if (namesEnvFile(arg)) return refuse(`${arg} is refused: ${NAMES_ENV_FILE}`);
246
+ } else if (arg === '--') {
247
+ pathsOnly = true;
248
+ } else {
249
+ const reason = arg.startsWith('--') ? checkLongOption(arg) || checkFormat(arg) : checkShortOption(arg, subcommand);
250
+ if (reason) return refuse(reason);
251
+ if (arg.startsWith('-L')) {
252
+ if (namesEnvFile(arg.slice(2))) return refuse(`${arg} is refused: ${NAMES_ENV_FILE}`);
253
+ if (PATHSPEC_SUBCOMMANDS.has(subcommand)) {
254
+ const lineRangeReason = checkLineRange(arg, index, subcommand);
255
+ if (lineRangeReason) return refuse(lineRangeReason);
256
+ lineRange = true;
257
+ }
258
+ }
259
+ }
260
+ }
261
+ const excludes = PATHSPEC_SUBCOMMANDS.has(subcommand) && !lineRange
262
+ ? [...(pathsOnly ? [] : ['--']), ...ENV_EXCLUDES]
263
+ : [];
264
+ return { ok: true, argv: [...TOP_LEVEL, subcommand, ...FORCED_OPTIONS[subcommand], ...args, ...excludes] };
265
+ };
266
+
267
+ // What git keeps from the seat's environment. Everything else is dropped,
268
+ // every GIT_* variable included.
269
+ const KEPT_ENV = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TZ']);
270
+
271
+ export const readonlyGitEnv = ({ root, baseEnv = process.env }) => {
272
+ const env = {};
273
+ for (const [key, value] of Object.entries(baseEnv || {})) {
274
+ if (KEPT_ENV.has(key) && typeof value === 'string') env[key] = value;
275
+ }
276
+ return {
277
+ ...env,
278
+ GIT_CONFIG_NOSYSTEM: '1',
279
+ GIT_CONFIG_GLOBAL: '/dev/null',
280
+ GIT_ATTR_NOSYSTEM: '1',
281
+ GIT_OPTIONAL_LOCKS: '0',
282
+ GIT_TERMINAL_PROMPT: '0',
283
+ GIT_NO_LAZY_FETCH: '1',
284
+ // A name no transport uses, so every protocol is refused.
285
+ GIT_ALLOW_PROTOCOL: 'none',
286
+ GIT_PAGER: 'cat',
287
+ PAGER: 'cat',
288
+ GIT_CEILING_DIRECTORIES: dirname(realOrResolved(root)),
289
+ };
290
+ };
291
+
292
+ const truncate = (text) => (text.length <= MAX_OUTPUT_CHARS
293
+ ? text
294
+ : `${text.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated at ${MAX_OUTPUT_CHARS} characters; narrow it with -n, a path, or a revision range]`);
295
+
296
+ /**
297
+ * Run one read-only git call in `root`. Resolves `{ ok, text }`; never rejects.
298
+ * `git` is the binary to run (the adapter resolves it outside the sandbox).
299
+ */
300
+ export const runReadonlyGit = ({
301
+ subcommand, args, root, git = 'git', baseEnv = process.env, timeoutMs = DEFAULT_TIMEOUT_MS,
302
+ }) => new Promise((done) => {
303
+ const checked = readonlyGitArgv({ subcommand, args, root });
304
+ if (!checked.ok) {
305
+ done({ ok: false, text: checked.reason });
306
+ return;
307
+ }
308
+ execFile(git, checked.argv, {
309
+ cwd: root,
310
+ env: readonlyGitEnv({ root, baseEnv }),
311
+ timeout: timeoutMs,
312
+ maxBuffer: MAX_BUFFER_BYTES,
313
+ encoding: 'utf8',
314
+ windowsHide: true,
315
+ }, (error, stdout, stderr) => {
316
+ const out = String(stdout || '');
317
+ if (!error) {
318
+ done({ ok: true, text: truncate(out || '(no output)') });
319
+ return;
320
+ }
321
+ const detail = String(stderr || '').trim() || error.message;
322
+ done({ ok: false, text: truncate([out.trim(), detail].filter(Boolean).join('\n')) });
323
+ });
324
+ });
325
+
326
+ export default {
327
+ READONLY_GIT_SUBCOMMANDS, DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS, readonlyGitArgv, readonlyGitEnv, runReadonlyGit,
328
+ };
@@ -60,7 +60,7 @@ export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, s
60
60
  export const buildMemoryPreamble = (
61
61
  prompt,
62
62
  memoryLongTerm,
63
- { freshSession = false, workCues = [] } = {},
63
+ { freshSession = false, workCues = [], memoryWithheld = false } = {},
64
64
  ) => {
65
65
  // `null` is the UNREADABLE signal, and it is deliberately not the same value
66
66
  // as `''`. Telling a seat whose token was revoked that "nothing has ever been
@@ -68,7 +68,14 @@ export const buildMemoryPreamble = (
68
68
  // defect this cue exists to fix — one state over. When we could not read, say
69
69
  // that, and say nothing about what is stored.
70
70
  let context;
71
- if (memoryLongTerm === null) {
71
+ if (memoryWithheld) {
72
+ // A workspace seat's turn that runs public: not unreadable and not empty,
73
+ // so neither of those cues is true. Its memory tools are off as well.
74
+ context = `=== Context (your persistent memory) ===\n`
75
+ + `(withheld this turn — this turn runs as a public seat because not every `
76
+ + `pod you answer in is confirmed private, so your memory stays out of it and `
77
+ + `the memory tools are off. This says nothing about what you have saved.)`;
78
+ } else if (memoryLongTerm === null) {
72
79
  context = `=== Context (your persistent memory) ===\n`
73
80
  + `(unreadable this turn — the memory read failed, so this says NOTHING `
74
81
  + `about what you have saved. Do not treat it as empty and do not re-save `
@@ -96,7 +103,9 @@ export const buildMemoryPreamble = (
96
103
  + 'This is a fresh session. Read the persistent memory context above before acting; '
97
104
  + 'it carries durable state from prior sessions.\n';
98
105
  let sessionEndCue = '=== Before this session ends ===\n';
99
- if (memoryLongTerm === null) {
106
+ if (memoryWithheld) {
107
+ sessionEndCue += 'Memory is withheld this turn, so there is nothing to save.';
108
+ } else if (memoryLongTerm === null) {
100
109
  sessionEndCue += 'Memory was unreadable on this fresh session. Do not treat it as empty or '
101
110
  + 'write a replacement based on this cue.';
102
111
  } else if (memoryLongTerm) {