@commonlyai/cli 0.1.179 → 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.
- package/node_modules/@commonlyai/seats/src/valet/env.js +6 -1
- package/package.json +1 -1
- package/src/commands/agent.js +184 -18
- package/src/lib/adapters/claude.js +131 -17
- package/src/lib/adapters/codex.js +2 -1
- package/src/lib/adapters/dsh.js +2 -1
- package/src/lib/adapters/goose.js +2 -1
- package/src/lib/adapters/opencode.js +2 -0
- package/src/lib/adapters/pi.js +2 -1
- package/src/lib/adapters/qwen.js +2 -1
- package/src/lib/default-environment.js +5 -3
- package/src/lib/environment.js +43 -5
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +328 -0
- package/src/lib/memory-bridge.js +12 -3
- package/src/lib/sandbox/host-check.js +3 -1
- package/src/lib/sandbox/mode.js +8 -1
- package/src/lib/sandbox/seatbelt.js +28 -1
- package/src/lib/workspace-trust.js +139 -0
package/src/lib/memory-bridge.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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) {
|
|
@@ -70,7 +70,9 @@ export const hostSandboxRefusal = ({
|
|
|
70
70
|
if (!Object.prototype.hasOwnProperty.call(ADAPTER_LABELS, adapterName)) return null;
|
|
71
71
|
const sandbox = normalizeSandboxTrust(environment?.sandbox);
|
|
72
72
|
if (!sandbox || typeof sandbox !== 'object') return null;
|
|
73
|
-
|
|
73
|
+
// Every host-confined trust (public, and workspace since TASK-294) derives its
|
|
74
|
+
// mode here; any other trust gets back only the mode it declared.
|
|
75
|
+
const mode = sandbox.mode ?? resolvePublicSandboxMode(sandbox, platform);
|
|
74
76
|
if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null;
|
|
75
77
|
|
|
76
78
|
if (platform === 'win32') return windowsSandboxMessage(adapterName);
|
package/src/lib/sandbox/mode.js
CHANGED
|
@@ -28,14 +28,21 @@
|
|
|
28
28
|
* NOT used by the codex adapter. Codex's `mode` is read-vs-write access, not a
|
|
29
29
|
* host mechanism — its permission profiles run on both platforms — so its
|
|
30
30
|
* derived default is 'workspace' everywhere.
|
|
31
|
+
*
|
|
32
|
+
* A `workspace` trust (TASK-294) resolves the same way: it is confined by the
|
|
33
|
+
* same host mechanism and differs only in what the policy inside allows. The
|
|
34
|
+
* grant-broker guard still reads the trust itself, not this mode, so resolving
|
|
35
|
+
* it here does not make a workspace seat eligible for a room grant.
|
|
31
36
|
*/
|
|
32
37
|
import { effectiveSandboxTrust } from '../environment.js';
|
|
33
38
|
|
|
34
39
|
export const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
|
|
35
40
|
|
|
41
|
+
const HOST_CONFINED_TRUSTS = new Set(['public', 'workspace']);
|
|
42
|
+
|
|
36
43
|
export const resolvePublicSandboxMode = (sandbox, platform = process.platform) => {
|
|
37
44
|
const declared = sandbox?.mode;
|
|
38
|
-
if (effectiveSandboxTrust(sandbox?.trust)
|
|
45
|
+
if (!HOST_CONFINED_TRUSTS.has(effectiveSandboxTrust(sandbox?.trust))) return declared;
|
|
39
46
|
if (declared !== undefined && declared !== null) return declared;
|
|
40
47
|
return platform === 'darwin' ? 'workspace' : 'bwrap';
|
|
41
48
|
};
|
|
@@ -300,6 +300,17 @@ const escapeSbplString = (value) => String(value)
|
|
|
300
300
|
|
|
301
301
|
const literal = (path) => `(literal "${escapeSbplString(path)}")`;
|
|
302
302
|
const subpath = (path) => `(subpath "${escapeSbplString(path)}")`;
|
|
303
|
+
// Every .env and .env.* file under the workspace, at any depth, and everything
|
|
304
|
+
// inside a directory of that name: a real project keeps one beside each
|
|
305
|
+
// package, not only at its root (TASK-294). An SBPL regex literal takes no
|
|
306
|
+
// escapes, so a path it cannot hold is refused.
|
|
307
|
+
const envFilesUnder = (workspace) => {
|
|
308
|
+
if (/["\r\n]/.test(workspace)) {
|
|
309
|
+
throw new Error('Seatbelt workspacePath must not contain a quote or a line break');
|
|
310
|
+
}
|
|
311
|
+
const pattern = workspace.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&');
|
|
312
|
+
return `(regex #"^${pattern}/(.*/)?\\.env([./].*)?$")`;
|
|
313
|
+
};
|
|
303
314
|
|
|
304
315
|
const assertAbsolutePath = (path, label) => {
|
|
305
316
|
if (!path || !isAbsolute(path)) {
|
|
@@ -356,6 +367,10 @@ export const buildSeatbeltProfile = ({
|
|
|
356
367
|
readOnlyDirectories = [],
|
|
357
368
|
loopbackNetworkPorts = [],
|
|
358
369
|
allowClaudeRuntimeAccess = false,
|
|
370
|
+
// Names directly inside a writable workspace that stay read-only. A
|
|
371
|
+
// workspace seat granted write keeps its project's .git read-only, so every
|
|
372
|
+
// change it makes is one `git status` shows the person (TASK-294).
|
|
373
|
+
readOnlyWorkspaceEntries = [],
|
|
359
374
|
}) => {
|
|
360
375
|
for (const [path, label] of [
|
|
361
376
|
[workspacePath, 'workspacePath'],
|
|
@@ -375,6 +390,11 @@ export const buildSeatbeltProfile = ({
|
|
|
375
390
|
if (!['read', 'write'].includes(workspaceAccess)) {
|
|
376
391
|
throw new Error('Seatbelt workspaceAccess must be read or write');
|
|
377
392
|
}
|
|
393
|
+
for (const entry of readOnlyWorkspaceEntries) {
|
|
394
|
+
if (typeof entry !== 'string' || !entry || entry.includes('/') || entry === '.' || entry === '..') {
|
|
395
|
+
throw new Error('Seatbelt readOnlyWorkspaceEntries entries must be names inside the workspace');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
378
398
|
|
|
379
399
|
const resolvedWorkspace = realpathOrSelf(workspacePath);
|
|
380
400
|
const resolvedExecutable = realpathOrSelf(executablePath);
|
|
@@ -411,6 +431,12 @@ export const buildSeatbeltProfile = ({
|
|
|
411
431
|
`(allow file-read* file-test-existence ${subpath(path)})`,
|
|
412
432
|
`(allow file-read-metadata file-test-existence (path-ancestors "${escapeSbplString(path)}"))`,
|
|
413
433
|
].join('\n'))).join('\n');
|
|
434
|
+
// A symlinked entry is held at both its name and its target, so the write
|
|
435
|
+
// cannot land through whichever path the link resolves to.
|
|
436
|
+
const readOnlyWorkspaceRules = [...new Set(readOnlyWorkspaceEntries.flatMap((entry) => {
|
|
437
|
+
const entryPath = join(resolvedWorkspace, entry);
|
|
438
|
+
return [entryPath, realpathOrSelf(entryPath)];
|
|
439
|
+
}))].map((path) => `(deny file-write* ${subpath(path)})`).join('\n');
|
|
414
440
|
const loopbackNetworkRules = [...new Set(loopbackNetworkPorts)].map((port) => (
|
|
415
441
|
`(allow network-outbound (remote tcp "localhost:${port}"))`
|
|
416
442
|
)).join('\n');
|
|
@@ -444,7 +470,8 @@ export const buildSeatbeltProfile = ({
|
|
|
444
470
|
// paths are not. Explicit deny wins over the broad workspace allow.
|
|
445
471
|
`(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.commonly'))})`,
|
|
446
472
|
`(deny file-read* file-write* ${subpath(join(resolvedWorkspace, '.codex'))})`,
|
|
447
|
-
`(deny file-read* file-write* ${
|
|
473
|
+
`(deny file-read* file-write* ${envFilesUnder(resolvedWorkspace)})`,
|
|
474
|
+
readOnlyWorkspaceRules,
|
|
448
475
|
...claudeRuntimeRules,
|
|
449
476
|
].filter(Boolean).join('\n\n');
|
|
450
477
|
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two facts a `workspace`-trust seat depends on (TASK-294), as pure
|
|
3
|
+
* predicates so the attach path, the first-run bootstrap and the per-spawn
|
|
4
|
+
* check all read them the same way.
|
|
5
|
+
*
|
|
6
|
+
* 1. Every pod the seat answers in is CONFIRMED private: not publicly
|
|
7
|
+
* readable, not listed, and invite-only. Visibility alone is not enough,
|
|
8
|
+
* because any agent can add itself to an open pod an agent created, listed
|
|
9
|
+
* or not. A pod whose fields the server did not report counts as public:
|
|
10
|
+
* the workspace tier lets the room read the start directory, so an unknown
|
|
11
|
+
* room fails closed.
|
|
12
|
+
* 2. The start directory is a directory worth exposing to that room, and not
|
|
13
|
+
* one that contains the operator's whole account: `/`, `$HOME`, an ancestor
|
|
14
|
+
* of `$HOME`, or a directory inside one of the credential stores the public
|
|
15
|
+
* policy denies by name.
|
|
16
|
+
*/
|
|
17
|
+
import { realpathSync } from 'fs';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import {
|
|
20
|
+
isAbsolute, join, relative, resolve,
|
|
21
|
+
} from 'path';
|
|
22
|
+
|
|
23
|
+
/** A pod is private only when the server says so in all three fields. */
|
|
24
|
+
export const podIsConfirmedPrivate = (pod) => (
|
|
25
|
+
typeof pod?.publicRead === 'boolean'
|
|
26
|
+
&& typeof pod?.communityListed === 'boolean'
|
|
27
|
+
&& !pod.publicRead
|
|
28
|
+
&& !pod.communityListed
|
|
29
|
+
&& pod.joinPolicy === 'invite-only'
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/** True only for a non-empty list in which every pod is confirmed private. */
|
|
33
|
+
export const allPodsConfirmedPrivate = (pods) => (
|
|
34
|
+
Array.isArray(pods) && pods.length > 0 && pods.every(podIsConfirmedPrivate)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// The stores claude.js denies by absolute path for a public seat. A start
|
|
38
|
+
// directory inside one of them would put the seat's own credentials in reach.
|
|
39
|
+
const CREDENTIAL_DIRS = ['.commonly', '.claude', '.codex', '.ssh', '.aws', '.config'];
|
|
40
|
+
|
|
41
|
+
const realOrResolved = (p) => {
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(p);
|
|
44
|
+
} catch {
|
|
45
|
+
return resolve(p);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const isInsideOrEqual = (child, parent) => {
|
|
50
|
+
const rel = relative(parent, child);
|
|
51
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Why `dir` cannot be a workspace seat's start directory, or `null` when it can.
|
|
56
|
+
* Symlinks are resolved first, so a link to `$HOME` is refused as `$HOME`.
|
|
57
|
+
*/
|
|
58
|
+
export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => {
|
|
59
|
+
if (typeof dir !== 'string' || !isAbsolute(dir)) {
|
|
60
|
+
return 'the start directory is not an absolute path';
|
|
61
|
+
}
|
|
62
|
+
const real = realOrResolved(dir);
|
|
63
|
+
const realHome = realOrResolved(home);
|
|
64
|
+
if (real === '/') return 'the start directory is /';
|
|
65
|
+
if (isInsideOrEqual(realHome, real)) {
|
|
66
|
+
return real === realHome
|
|
67
|
+
? 'the start directory is your home directory'
|
|
68
|
+
: 'the start directory contains your home directory';
|
|
69
|
+
}
|
|
70
|
+
const store = CREDENTIAL_DIRS.find((name) => isInsideOrEqual(real, join(realHome, name)));
|
|
71
|
+
if (store) return `the start directory is inside ~/${store}`;
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable or open to join, or did not report whether it is';
|
|
76
|
+
// claude.js refuses a workspace seat under bwrap, so off macOS the default
|
|
77
|
+
// would be a seat that fails every turn rather than one that reads a project.
|
|
78
|
+
const NOT_MACOS_REASON = 'an agent works in its folder only on macOS for now';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Where a first-run claude seat may read (TASK-294), from three facts the
|
|
82
|
+
* bootstrap already holds: the adapter it detected, the rooms its token
|
|
83
|
+
* answers in, and the directory the person ran it from. It returns the start
|
|
84
|
+
* directory only when all three allow it, and otherwise the reason the seat
|
|
85
|
+
* keeps the `public` default it had before, plus a `fix` when the person can
|
|
86
|
+
* change the outcome by starting somewhere else. Any other adapter gets `null`:
|
|
87
|
+
* `workspace` is claude-only for now, so there is nothing to decide.
|
|
88
|
+
*/
|
|
89
|
+
export const firstRunWorkspace = ({
|
|
90
|
+
adapterName, installations, startDir, home, platform = process.platform,
|
|
91
|
+
} = {}) => {
|
|
92
|
+
if (adapterName !== 'claude') return null;
|
|
93
|
+
if (platform !== 'darwin') return { workspacePath: null, reason: NOT_MACOS_REASON };
|
|
94
|
+
if (!allPodsConfirmedPrivate(installations)) {
|
|
95
|
+
return { workspacePath: null, reason: PUBLIC_POD_REASON };
|
|
96
|
+
}
|
|
97
|
+
const refusal = workspaceStartDirRefusal(startDir, home === undefined ? {} : { home });
|
|
98
|
+
if (refusal) {
|
|
99
|
+
return {
|
|
100
|
+
workspacePath: null,
|
|
101
|
+
reason: refusal,
|
|
102
|
+
fix: 'To let it read a project, run this command from that project\'s folder.',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { workspacePath: realOrResolved(startDir), reason: null };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Where one turn of a seat runs, and under which trust. A pod can turn public
|
|
110
|
+
* after attach, so a `workspace` seat's rooms are re-read before every turn
|
|
111
|
+
* (`installations` is that read, or `null` when it failed). A turn that cannot
|
|
112
|
+
* confirm them all private runs as a `public` seat in its scratch folder: the
|
|
113
|
+
* confinement it would have had without the workspace. Any other trust runs
|
|
114
|
+
* where and as it was started.
|
|
115
|
+
*/
|
|
116
|
+
export const workspaceTurnPlacement = ({
|
|
117
|
+
environment, workspacePath, installations, scratchDir,
|
|
118
|
+
} = {}) => {
|
|
119
|
+
const cwd = workspacePath || scratchDir;
|
|
120
|
+
if (environment?.sandbox?.trust !== 'workspace' || allPodsConfirmedPrivate(installations)) {
|
|
121
|
+
return { environment, cwd, reason: null };
|
|
122
|
+
}
|
|
123
|
+
// Its write/web grants widen the workspace tier only, so the public turn
|
|
124
|
+
// drops them rather than carry them into a room strangers can read.
|
|
125
|
+
const { allow: _grants, ...sandbox } = environment.sandbox;
|
|
126
|
+
return {
|
|
127
|
+
environment: { ...environment, sandbox: { ...sandbox, trust: 'public' } },
|
|
128
|
+
cwd: scratchDir,
|
|
129
|
+
reason: installations === null ? 'its pods could not be read' : PUBLIC_POD_REASON,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export default {
|
|
134
|
+
podIsConfirmedPrivate,
|
|
135
|
+
allPodsConfirmedPrivate,
|
|
136
|
+
workspaceStartDirRefusal,
|
|
137
|
+
firstRunWorkspace,
|
|
138
|
+
workspaceTurnPlacement,
|
|
139
|
+
};
|