@commonlyai/cli 0.1.200 → 0.1.202
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/package.json
CHANGED
|
@@ -96,6 +96,7 @@ import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
|
96
96
|
import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
|
|
97
97
|
import { skillsAsText } from '../skill-prompt.js';
|
|
98
98
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
99
|
+
import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
|
|
99
100
|
|
|
100
101
|
// See codex.js for the rationale on bumping the default + env override.
|
|
101
102
|
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
@@ -446,6 +447,10 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memor
|
|
|
446
447
|
const memoryDenies = memoryWithheld
|
|
447
448
|
? MEMORY_TOOLS.flatMap((tool) => mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, tool)))
|
|
448
449
|
: [];
|
|
450
|
+
// Nor, on any confined trust, the tools no public seat is offered
|
|
451
|
+
// (public-withheld-tools.js).
|
|
452
|
+
const withheldDenies = PUBLIC_WITHHELD_TOOLS
|
|
453
|
+
.flatMap((tool) => mcpToolPatterns.map((pattern) => pattern.replace(/\*$/, tool)));
|
|
449
454
|
return [
|
|
450
455
|
// HOME points at an isolated, sanitized state directory. Ignore settings
|
|
451
456
|
// from user/project/local sources and disable slash-command extensions;
|
|
@@ -457,7 +462,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memor
|
|
|
457
462
|
'--permission-mode', 'dontAsk',
|
|
458
463
|
'--allowedTools', 'Read(./**)', ...granted, ...mcpToolPatterns,
|
|
459
464
|
'--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.includes(tool)),
|
|
460
|
-
...deniedReads, ...workspaceDenies, ...memoryDenies,
|
|
465
|
+
...deniedReads, ...workspaceDenies, ...memoryDenies, ...withheldDenies,
|
|
461
466
|
];
|
|
462
467
|
};
|
|
463
468
|
|
|
@@ -71,6 +71,7 @@ import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
|
71
71
|
import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
|
|
72
72
|
import { isLegacySandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust } from '../environment.js';
|
|
73
73
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
74
|
+
import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
|
|
74
75
|
|
|
75
76
|
// Default timeout for a single codex spawn (exec mode).
|
|
76
77
|
//
|
|
@@ -212,6 +213,10 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
|
|
|
212
213
|
'-c',
|
|
213
214
|
`mcp_servers.${server.name}.default_tools_approval_mode="approve"`,
|
|
214
215
|
);
|
|
216
|
+
// ...except the tools no public seat is offered (public-withheld-tools.js).
|
|
217
|
+
if (ctx.publicSeat) {
|
|
218
|
+
flags.push('-c', `mcp_servers.${server.name}.disabled_tools=[${PUBLIC_WITHHELD_TOOLS.map(toml).join(',')}]`);
|
|
219
|
+
}
|
|
215
220
|
if (rest.length) {
|
|
216
221
|
flags.push('-c', `mcp_servers.${server.name}.args=[${rest.map(toml).join(',')}]`);
|
|
217
222
|
}
|
|
@@ -58,6 +58,7 @@ import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js
|
|
|
58
58
|
import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
|
|
59
59
|
import { isAnswerableToolError } from '../public-tool-error.js';
|
|
60
60
|
import { startInstanceProxy } from '../instance-proxy.js';
|
|
61
|
+
import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
|
|
61
62
|
|
|
62
63
|
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
63
64
|
const ISOLATED_PROXY_PORT = 8765;
|
|
@@ -246,6 +247,10 @@ const publicPermissions = (mode, mcpNames) => {
|
|
|
246
247
|
todoread: 'allow',
|
|
247
248
|
lsp: 'allow',
|
|
248
249
|
...Object.fromEntries(mcpNames.map((name) => [`${permissionMcpName(name)}_*`, 'allow'])),
|
|
250
|
+
// After the wildcard allow, since the last matching rule wins
|
|
251
|
+
// (public-withheld-tools.js).
|
|
252
|
+
...Object.fromEntries(mcpNames.flatMap((name) => PUBLIC_WITHHELD_TOOLS
|
|
253
|
+
.map((tool) => [`${permissionMcpName(name)}_${tool}`, 'deny']))),
|
|
249
254
|
};
|
|
250
255
|
};
|
|
251
256
|
|
package/src/lib/adapters/qwen.js
CHANGED
|
@@ -15,6 +15,7 @@ import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
|
|
|
15
15
|
import { wrapArgvWithSeatbelt } from '../sandbox/seatbelt.js';
|
|
16
16
|
import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js';
|
|
17
17
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
18
|
+
import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
|
|
18
19
|
|
|
19
20
|
// Add a version only with a passing real clean-box sandbox smoke that names it.
|
|
20
21
|
const TESTED_QWEN_VERSIONS = Object.freeze(['0.25.1', '0.26.2']);
|
|
@@ -38,6 +39,9 @@ const EXCLUDED_TOOLS = [
|
|
|
38
39
|
// per-spawn MCP credential file, so public turns cannot expose OS read tools.
|
|
39
40
|
const PUBLIC_READ_TOOLS = [
|
|
40
41
|
'read_file', 'grep_search', 'search_file_content', 'glob', 'list_directory', 'lsp',
|
|
42
|
+
// Both spellings of a Commonly MCP tool, as with the aliases above
|
|
43
|
+
// (public-withheld-tools.js).
|
|
44
|
+
...PUBLIC_WITHHELD_TOOLS.flatMap((tool) => [tool, `mcp__commonly__${tool}`]),
|
|
41
45
|
];
|
|
42
46
|
|
|
43
47
|
const parseVersion = (output) => {
|
|
@@ -2,14 +2,20 @@ import { Buffer } from 'node:buffer';
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import {
|
|
4
4
|
accessSync,
|
|
5
|
+
closeSync,
|
|
5
6
|
constants,
|
|
7
|
+
fstatSync,
|
|
8
|
+
lstatSync,
|
|
6
9
|
mkdtempSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readlinkSync,
|
|
7
13
|
rmSync,
|
|
8
14
|
statSync,
|
|
9
15
|
writeFileSync,
|
|
10
16
|
} from 'node:fs';
|
|
11
17
|
import { tmpdir } from 'node:os';
|
|
12
|
-
import { join, resolve } from 'node:path';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
19
|
import { spawnSync } from 'node:child_process';
|
|
14
20
|
|
|
15
21
|
const GIT_HOOKS = [
|
|
@@ -77,6 +83,145 @@ const seatAddressId = (agentName, instanceId) => {
|
|
|
77
83
|
// resolves the original hooks itself at commit time.
|
|
78
84
|
const GIT_PROBE_TIMEOUT_MS = 5000;
|
|
79
85
|
|
|
86
|
+
const directoryOrNull = (path) => {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(path).isDirectory() ? path : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// A regular file's text, or null. The open does not block, so a FIFO or a
|
|
95
|
+
// device where git keeps a file is refused instead of hanging the caller.
|
|
96
|
+
const readRegularFile = (path) => {
|
|
97
|
+
let fd = null;
|
|
98
|
+
try {
|
|
99
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
100
|
+
return fstatSync(fd).isFile() ? readFileSync(fd, 'utf8') : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
} finally {
|
|
104
|
+
if (fd !== null) closeSync(fd);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// One git config value: quotes group, a backslash escapes the next character,
|
|
109
|
+
// and an unquoted # or ; starts a comment. Whitespace around the value is
|
|
110
|
+
// dropped unless it is quoted.
|
|
111
|
+
const configValue = (raw) => {
|
|
112
|
+
let value = '';
|
|
113
|
+
let kept = 0;
|
|
114
|
+
let quoted = false;
|
|
115
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
116
|
+
const character = raw[index];
|
|
117
|
+
if (character === '\\' && index + 1 < raw.length) {
|
|
118
|
+
index += 1;
|
|
119
|
+
value += raw[index];
|
|
120
|
+
kept = value.length;
|
|
121
|
+
} else if (character === '"') {
|
|
122
|
+
quoted = !quoted;
|
|
123
|
+
} else if (!quoted && (character === '#' || character === ';')) {
|
|
124
|
+
break;
|
|
125
|
+
} else if (quoted || value || !/\s/.test(character)) {
|
|
126
|
+
value += character;
|
|
127
|
+
if (quoted) kept = value.length;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return value.slice(0, kept) + value.slice(kept).trimEnd();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// The last core.hooksPath in one config file, or null. `include` and
|
|
134
|
+
// conditional includes are not followed.
|
|
135
|
+
const configuredHooksPath = (configPath) => {
|
|
136
|
+
const text = readRegularFile(configPath);
|
|
137
|
+
if (text === null) return null;
|
|
138
|
+
let inCore = false;
|
|
139
|
+
let hooksPath = null;
|
|
140
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
141
|
+
let line = rawLine.trim();
|
|
142
|
+
const section = /^\[\s*([A-Za-z0-9.-]+)\s*("[^"]*")?\s*\]/.exec(line);
|
|
143
|
+
if (section) {
|
|
144
|
+
inCore = section[1].toLowerCase() === 'core' && !section[2];
|
|
145
|
+
line = line.slice(section[0].length).trim();
|
|
146
|
+
}
|
|
147
|
+
const entry = inCore && /^hookspath\s*=(.*)$/i.exec(line);
|
|
148
|
+
if (entry) hooksPath = configValue(entry[1]);
|
|
149
|
+
}
|
|
150
|
+
return hooksPath || null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Git opens a git directory only if its HEAD names a ref or an object id and
|
|
154
|
+
// its common directory holds objects/ and refs/.
|
|
155
|
+
const isGitDirectory = (gitDir, commonDir) => {
|
|
156
|
+
if (!directoryOrNull(join(commonDir, 'objects')) || !directoryOrNull(join(commonDir, 'refs'))) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const head = join(gitDir, 'HEAD');
|
|
160
|
+
try {
|
|
161
|
+
if (lstatSync(head).isSymbolicLink()) return readlinkSync(head).startsWith('refs/');
|
|
162
|
+
const line = (readRegularFile(head) ?? '').split('\n')[0];
|
|
163
|
+
return /^ref:\s*refs\//.test(line) || /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(line);
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Git refuses a repository whose files another user owns, unless global
|
|
170
|
+
// safe.directory config allows it. That config is not read here, so such a
|
|
171
|
+
// repository yields no hooks, which is git's own answer without it.
|
|
172
|
+
const ownedByThisUser = (path) => {
|
|
173
|
+
if (typeof process.geteuid !== 'function') return false;
|
|
174
|
+
try {
|
|
175
|
+
return lstatSync(path).uid === process.geteuid();
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// The hooks directory a commit in `cwd` would use, read from the repository's
|
|
182
|
+
// own files without running git: core.hooksPath from the repository config,
|
|
183
|
+
// else the hooks folder of the common git directory (a linked worktree shares
|
|
184
|
+
// its main repository's). Global config and includes are not read. `~` expands
|
|
185
|
+
// against the HOME the probe gave git, as git itself would. The nearest .git
|
|
186
|
+
// must be a repository this user owns, or there are no hooks. Git would walk
|
|
187
|
+
// on past a .git directory that is not a repository, and this stops there.
|
|
188
|
+
const hooksPathFromGitFiles = (cwd, env) => {
|
|
189
|
+
let top = resolve(cwd);
|
|
190
|
+
let entry = join(top, '.git');
|
|
191
|
+
let entryStat = null;
|
|
192
|
+
for (;;) {
|
|
193
|
+
try {
|
|
194
|
+
entryStat = statSync(entry);
|
|
195
|
+
break;
|
|
196
|
+
} catch {
|
|
197
|
+
if (dirname(top) === top) return null;
|
|
198
|
+
top = dirname(top);
|
|
199
|
+
entry = join(top, '.git');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Ownership is settled before any of the repository's files is read.
|
|
203
|
+
if (!ownedByThisUser(top) || !ownedByThisUser(entry)) return null;
|
|
204
|
+
let gitDir = entry;
|
|
205
|
+
if (entryStat.isFile()) {
|
|
206
|
+
const pointer = /^gitdir:\s*(.+?)\s*$/m.exec(readRegularFile(entry) ?? '');
|
|
207
|
+
if (!pointer) return null;
|
|
208
|
+
gitDir = resolve(top, pointer[1]);
|
|
209
|
+
if (!ownedByThisUser(gitDir)) return null;
|
|
210
|
+
}
|
|
211
|
+
// A main repository has no commondir file.
|
|
212
|
+
const commonDirFile = readRegularFile(join(gitDir, 'commondir'));
|
|
213
|
+
const commonDir = commonDirFile === null ? gitDir : resolve(gitDir, commonDirFile.trim());
|
|
214
|
+
if (!isGitDirectory(gitDir, commonDir)) return null;
|
|
215
|
+
const configured = configuredHooksPath(join(commonDir, 'config'));
|
|
216
|
+
if (!configured) return directoryOrNull(join(commonDir, 'hooks'));
|
|
217
|
+
if (configured === '~' || configured.startsWith('~/')) {
|
|
218
|
+
return env.HOME ? directoryOrNull(join(env.HOME, configured.slice(1))) : null;
|
|
219
|
+
}
|
|
220
|
+
// Git runs hooks from the top of the working tree, so a relative path
|
|
221
|
+
// resolves there.
|
|
222
|
+
return directoryOrNull(resolve(top, configured));
|
|
223
|
+
};
|
|
224
|
+
|
|
80
225
|
const resolveOriginalHooksPath = (cwd, env) => {
|
|
81
226
|
const result = spawnSync(
|
|
82
227
|
'git',
|
|
@@ -90,13 +235,31 @@ const resolveOriginalHooksPath = (cwd, env) => {
|
|
|
90
235
|
killSignal: 'SIGKILL',
|
|
91
236
|
},
|
|
92
237
|
);
|
|
93
|
-
if (result.error
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
238
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
239
|
+
// The open() that stalled git would stall this process too, and a
|
|
240
|
+
// synchronous read cannot be timed out, so nothing is read here. A git
|
|
241
|
+
// that cannot open the repository cannot commit in it either.
|
|
242
|
+
// eslint-disable-next-line no-console
|
|
243
|
+
console.warn(`[commit-attribution] git did not answer in ${GIT_PROBE_TIMEOUT_MS / 1000}s in ${cwd}, `
|
|
244
|
+
+ 'so a confined seat is not granted this repository\'s hooks this turn');
|
|
98
245
|
return null;
|
|
99
246
|
}
|
|
247
|
+
if (result.error || result.status !== 0 || !result.stdout?.trim()) {
|
|
248
|
+
// A confined seat can only run the hooks it is granted. A hook it is not
|
|
249
|
+
// granted is skipped without a word, or refuses the commit where the jail
|
|
250
|
+
// can see the file but not run it. So read the hooks directory from the
|
|
251
|
+
// repository's files instead.
|
|
252
|
+
const fallback = hooksPathFromGitFiles(cwd, env);
|
|
253
|
+
if (fallback) {
|
|
254
|
+
const reason = result.error?.code || `exit ${result.status}`;
|
|
255
|
+
// eslint-disable-next-line no-console
|
|
256
|
+
console.warn(`[commit-attribution] git could not report the hooks directory (${reason}), `
|
|
257
|
+
+ `so ${fallback} was read from the repository's files`);
|
|
258
|
+
}
|
|
259
|
+
return fallback;
|
|
260
|
+
}
|
|
261
|
+
// Only git's newline ends the line, since a path can end in a space.
|
|
262
|
+
return directoryOrNull(resolve(result.stdout.replace(/\r?\n$/, '')));
|
|
100
263
|
};
|
|
101
264
|
|
|
102
265
|
const resolveGitConfigCount = (env) => {
|
|
@@ -192,8 +355,8 @@ export const prepareCommitAttribution = ({
|
|
|
192
355
|
COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: String(configIndex),
|
|
193
356
|
COMMONLY_AGENT_HOOKS_PATH: hooksDirectory,
|
|
194
357
|
// The sandbox adapters allow the workspace's existing hook files. The
|
|
195
|
-
// proxy resolves the target repo's path at hook time
|
|
196
|
-
//
|
|
358
|
+
// proxy resolves the target repo's path at hook time, and a confined seat
|
|
359
|
+
// cannot run a hook outside what it was granted.
|
|
197
360
|
...(originalHooksPath ? { COMMONLY_AGENT_ORIGINAL_HOOKS_PATH: originalHooksPath } : {}),
|
|
198
361
|
COMMONLY_AGENT_SEAT_NAME: cleanSingleLine(displayName || agentName, 'agent'),
|
|
199
362
|
COMMONLY_AGENT_SEAT_ID: seatAddressId(agentName, instanceId),
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Commonly MCP tools that public seats are not offered, on any adapter.
|
|
2
|
+
// Claude workspace seats follow the same list. Each
|
|
3
|
+
// adapter removes them in its own vocabulary (TASK-287; Goose's public list
|
|
4
|
+
// already leaves them out).
|
|
5
|
+
export const PUBLIC_WITHHELD_TOOLS = Object.freeze(['commonly_attach_file']);
|