@commonlyai/cli 0.1.172 → 0.1.174
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/README.md +14 -4
- package/node_modules/@commonlyai/seats/README.md +11 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +206 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +38 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +261 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +635 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +45 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +362 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +164 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +118 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/package.json +6 -1
- package/src/commands/agent.js +19 -8
- package/src/lib/adapters/claude.js +8 -3
- package/src/lib/adapters/codex.js +5 -2
- package/src/lib/adapters/hermes.js +13 -194
- package/src/lib/adapters/seat-model.js +5 -12
- package/src/lib/adapters/stub.js +1 -24
- package/src/lib/runtime-observation.js +3 -27
- package/src/lib/seat-stop.js +96 -0
- package/src/lib/upstream-refusal.js +1 -354
- package/src/lib/handoff-memory.js +0 -23
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the env and HOME one run executes in. The run starts from nothing the
|
|
3
|
+
* user's login can be found through: a short allowlist of locale/terminal
|
|
4
|
+
* variables, a fresh HOME whose XDG dirs sit inside it, and only the
|
|
5
|
+
* credentials its grant names.
|
|
6
|
+
*
|
|
7
|
+
* What this is NOT: a filesystem sandbox. A process that already knows an
|
|
8
|
+
* absolute path (/Users/<you>/.claude) can still open it, and macOS keychain
|
|
9
|
+
* items are reached through the user's security session, not through HOME.
|
|
10
|
+
* This removes the paths a CLI discovers its login by; an OS sandbox
|
|
11
|
+
* (Seatbelt, bwrap) is what stops a process that goes looking.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { mkdtemp, mkdir, chmod, readFile, rm } from 'node:fs/promises';
|
|
15
|
+
import { homedir, tmpdir } from 'node:os';
|
|
16
|
+
import { delimiter, join, resolve, sep } from 'node:path';
|
|
17
|
+
import { traceOf } from '../trace/events.js';
|
|
18
|
+
import { runFailure } from '../failures.js';
|
|
19
|
+
import { MIN_CREDENTIAL_LENGTH } from '../upstream-refusal.js';
|
|
20
|
+
import { validateGrant } from './grant.js';
|
|
21
|
+
|
|
22
|
+
// Same spirit as the goose adapter's SAFE_ENV: locale, terminal and TLS trust
|
|
23
|
+
// only. USER/LOGNAME are left out on purpose; they name the account whose
|
|
24
|
+
// login this boundary exists to hide.
|
|
25
|
+
export const BASE_ENV_ALLOWLIST = /^(?:PATH|LANG|LC_[A-Z0-9_]+|TERM|TZ|SSL_CERT_FILE|SSL_CERT_DIR|NO_COLOR|CI)$/;
|
|
26
|
+
|
|
27
|
+
const isInside = (path, dir) => {
|
|
28
|
+
const a = resolve(path);
|
|
29
|
+
const b = resolve(dir);
|
|
30
|
+
return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* PATH entries under the real home both name it and often hold shims that
|
|
35
|
+
* read the user's login (~/.local/bin/claude). Adapters run their CLI by
|
|
36
|
+
* absolute path, so the run loses nothing it needs.
|
|
37
|
+
*/
|
|
38
|
+
const scrubPath = (value, realHomes) => value.split(delimiter)
|
|
39
|
+
.filter((entry) => entry && !realHomes.some((home) => isInside(entry, home)))
|
|
40
|
+
.join(delimiter);
|
|
41
|
+
|
|
42
|
+
const pickBaseEnv = (baseEnv, realHomes) => {
|
|
43
|
+
const env = {};
|
|
44
|
+
for (const [name, value] of Object.entries(baseEnv)) {
|
|
45
|
+
if (typeof value === 'string' && BASE_ENV_ALLOWLIST.test(name)) env[name] = value;
|
|
46
|
+
}
|
|
47
|
+
if (env.PATH !== undefined) env.PATH = scrubPath(env.PATH, realHomes);
|
|
48
|
+
return env;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const makeHome = async (root) => {
|
|
52
|
+
const home = await mkdtemp(join(root || tmpdir(), 'seat-home-'));
|
|
53
|
+
// Conventional layout, so a CLI that ignores XDG_* and defaults to
|
|
54
|
+
// ~/.config still lands inside the throwaway HOME.
|
|
55
|
+
const dirs = {
|
|
56
|
+
XDG_CONFIG_HOME: join(home, '.config'),
|
|
57
|
+
XDG_DATA_HOME: join(home, '.local', 'share'),
|
|
58
|
+
XDG_STATE_HOME: join(home, '.local', 'state'),
|
|
59
|
+
XDG_CACHE_HOME: join(home, '.cache'),
|
|
60
|
+
TMPDIR: join(home, 'tmp'),
|
|
61
|
+
};
|
|
62
|
+
await chmod(home, 0o700);
|
|
63
|
+
for (const dir of Object.values(dirs)) await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
64
|
+
// mkdir's mode is masked by umask; set it explicitly on every level made.
|
|
65
|
+
for (const dir of [join(home, '.local'), ...Object.values(dirs)]) await chmod(dir, 0o700);
|
|
66
|
+
return { home, dirs };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A value that cannot ride an env var is the same as no value. */
|
|
70
|
+
const usable = (value) => typeof value === 'string' && value.length > 0 && !value.includes('\0');
|
|
71
|
+
|
|
72
|
+
const resolveOne = async (spec, { baseEnv, resolveSecret }) => {
|
|
73
|
+
if (spec.from === 'env') return baseEnv[spec.ref || spec.name];
|
|
74
|
+
if (spec.from === 'file') return (await readFile(spec.ref, 'utf8')).replace(/\r?\n$/, '');
|
|
75
|
+
if (typeof resolveSecret !== 'function') return undefined;
|
|
76
|
+
return resolveSecret(spec.name, spec);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The grant.issued entry for grantMeta. Its scope is the room grant this run
|
|
81
|
+
* acts under (exact tool names, a connection id), as the host knows it; the
|
|
82
|
+
* run grant's own credential names and patterns show up in credential.* and
|
|
83
|
+
* in each permission.decided rule. `taskId` + `podId` bind the grant to that
|
|
84
|
+
* board task, ending when it is done unless `endsOn` says otherwise.
|
|
85
|
+
*/
|
|
86
|
+
const grantIssued = (meta) => {
|
|
87
|
+
const {
|
|
88
|
+
grantId = randomUUID(), parentGrantId, scope = {}, target, issuedBy, expiresAt, taskId, podId, endsOn = 'task.done',
|
|
89
|
+
} = meta;
|
|
90
|
+
return {
|
|
91
|
+
grantId,
|
|
92
|
+
...(parentGrantId === undefined ? {} : { parentGrantId }),
|
|
93
|
+
scope: { tools: [], audience: [], ...scope },
|
|
94
|
+
...(target ? { target } : {}),
|
|
95
|
+
...(issuedBy ? { issuedBy } : {}),
|
|
96
|
+
...(expiresAt ? { expiresAt: expiresAt instanceof Date ? expiresAt.toISOString() : expiresAt } : {}),
|
|
97
|
+
...(taskId ? { binding: { taskId, podId, endsOn } } : {}),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta? })
|
|
103
|
+
* → { env, home, cleanup, granted: [names], grantId? }
|
|
104
|
+
*
|
|
105
|
+
* `require` lists credential names the adapter cannot run without; one the
|
|
106
|
+
* grant does not name fails the run as credential_denied before any spawn.
|
|
107
|
+
* A named credential that cannot be resolved, or that resolves to fewer than
|
|
108
|
+
* MIN_CREDENTIAL_LENGTH characters, fails it the same way: a run that
|
|
109
|
+
* silently lacks its key fails later and less legibly, and a value that short
|
|
110
|
+
* would slip past the refusal-text reader's redaction.
|
|
111
|
+
*
|
|
112
|
+
* Events go to `trace`, else ctx.trace: grant.issued (only with grantMeta,
|
|
113
|
+
* first), then credential.granted {names} or credential.denied {name, reason}.
|
|
114
|
+
* Values never enter an event or a message, and a hook that offers
|
|
115
|
+
* redact(values) is handed the resolved values so the record can scrub them
|
|
116
|
+
* out of agent text too.
|
|
117
|
+
*/
|
|
118
|
+
export const prepareRunEnv = async ({
|
|
119
|
+
grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta,
|
|
120
|
+
} = {}) => {
|
|
121
|
+
const valid = validateGrant(grant);
|
|
122
|
+
const hook = trace && typeof trace.emit === 'function' ? trace : traceOf(ctx);
|
|
123
|
+
const issued = grantMeta ? grantIssued(grantMeta) : null;
|
|
124
|
+
if (issued) hook.emit('grant.issued', issued);
|
|
125
|
+
const realHomes = [...new Set([baseEnv.HOME, homedir()].filter(Boolean))];
|
|
126
|
+
const DENIED = {
|
|
127
|
+
not_granted: 'is not in the run grant',
|
|
128
|
+
unresolved: 'could not be resolved',
|
|
129
|
+
too_short: `resolved to a value shorter than ${MIN_CREDENTIAL_LENGTH} characters`,
|
|
130
|
+
};
|
|
131
|
+
const deny = (name, reason, values) => {
|
|
132
|
+
hook.emit('credential.denied', { name, reason });
|
|
133
|
+
return runFailure('credential_denied', `${name} ${DENIED[reason]}`, { credentials: values });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const granted = new Set(valid.credentials.map((c) => c.name));
|
|
137
|
+
const missing = required.find((name) => !granted.has(name));
|
|
138
|
+
if (missing) throw deny(missing, 'not_granted', []);
|
|
139
|
+
|
|
140
|
+
const { home, dirs } = await makeHome(root);
|
|
141
|
+
const cleanup = () => rm(home, { recursive: true, force: true });
|
|
142
|
+
const env = { ...pickBaseEnv(baseEnv, realHomes), HOME: home, ...dirs };
|
|
143
|
+
const values = [];
|
|
144
|
+
try {
|
|
145
|
+
for (const spec of valid.credentials) {
|
|
146
|
+
let value;
|
|
147
|
+
// A resolver's error text may quote the secret it failed on; drop it.
|
|
148
|
+
try { value = await resolveOne(spec, { baseEnv, resolveSecret }); } catch { value = undefined; }
|
|
149
|
+
if (!usable(value)) throw deny(spec.name, 'unresolved', values);
|
|
150
|
+
// A value this short cannot be told apart from ordinary text, so the
|
|
151
|
+
// refusal-text reader would not redact it. Refuse it here, before spawn.
|
|
152
|
+
if (value.length < MIN_CREDENTIAL_LENGTH) throw deny(spec.name, 'too_short', values);
|
|
153
|
+
values.push(value);
|
|
154
|
+
env[spec.name] = value;
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
await cleanup();
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (typeof hook.redact === 'function') hook.redact(values);
|
|
161
|
+
const names = valid.credentials.map((c) => c.name);
|
|
162
|
+
hook.emit('credential.granted', { names });
|
|
163
|
+
return { env, home, cleanup, granted: names, ...(issued ? { grantId: issued.grantId } : {}) };
|
|
164
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A grant says what one run may hold and do: which credentials reach its env,
|
|
3
|
+
* and which tool calls it may make without asking, only after asking, or never.
|
|
4
|
+
*
|
|
5
|
+
* {
|
|
6
|
+
* credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref? }],
|
|
7
|
+
* tools: { allow: [pattern], ask: [pattern], deny: [pattern] },
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* `from: 'env'` copies baseEnv[ref ?? name]; `from: 'file'` reads the absolute
|
|
11
|
+
* path in `ref`; `from: 'resolver'` asks the host's resolveSecret(name, spec),
|
|
12
|
+
* with `ref` as an opaque hint for it.
|
|
13
|
+
*
|
|
14
|
+
* A tool pattern is `<kind>` or `<kind>:<title glob>`, where kind is one of
|
|
15
|
+
* ACP's ToolKind values or `*`. `execute` matches every execute call;
|
|
16
|
+
* `execute:git status*` matches execute calls whose title starts that way;
|
|
17
|
+
* `*:*.env*` matches any kind by title. Globs know `*` and `?` only.
|
|
18
|
+
*
|
|
19
|
+
* The title is text the agent chose. A title-glob deny narrows what the agent
|
|
20
|
+
* can do; a title-glob allow is only as strong as the agent's honesty about
|
|
21
|
+
* its title, so prefer kind-level allows and put execute behind ask.
|
|
22
|
+
*
|
|
23
|
+
* Precedence is deny > ask > allow, and a call no rule names is denied.
|
|
24
|
+
*
|
|
25
|
+
* `network` rules are refused for now rather than accepted and ignored: a run
|
|
26
|
+
* would believe it was fenced when nothing enforces the fence. TODO: egress
|
|
27
|
+
* allowlist once a sandbox can enforce it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const TOOL_KINDS = Object.freeze(['read', 'edit', 'delete', 'move', 'search', 'execute', 'think', 'fetch', 'other']);
|
|
31
|
+
export const CREDENTIAL_SOURCES = Object.freeze(['env', 'file', 'resolver']);
|
|
32
|
+
const RULE_LISTS = ['deny', 'ask', 'allow']; // checked in this order: the precedence
|
|
33
|
+
const VALIDATED = Symbol('validated grant');
|
|
34
|
+
|
|
35
|
+
const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
36
|
+
// Names the run env is built from. A credential under one of these names would
|
|
37
|
+
// overwrite the boundary itself (a granted HOME points back at the real login).
|
|
38
|
+
const RESERVED_NAME = /^(?:HOME|PATH|TMPDIR|USER|LOGNAME|SHELL|PWD|LANG|LC_[A-Z0-9_]+|TERM|TZ|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR|XDG_[A-Z0-9_]+)$/;
|
|
39
|
+
|
|
40
|
+
const fail = (message) => { throw new Error(`grant: ${message}`); };
|
|
41
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
const refuseUnknownKeys = (value, allowed, where) => {
|
|
43
|
+
for (const key of Object.keys(value)) {
|
|
44
|
+
if (!allowed.includes(key)) fail(`unknown field ${where}${key}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const globToRegExp = (glob) => new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[\\s\\S]*').replace(/\?/g, '[\\s\\S]')}$`);
|
|
49
|
+
|
|
50
|
+
/** Parse once at validation so matching never re-reads a malformed pattern. */
|
|
51
|
+
const parsePattern = (pattern, where) => {
|
|
52
|
+
if (typeof pattern !== 'string' || !pattern.trim()) fail(`${where} must be a non-empty string`);
|
|
53
|
+
const colon = pattern.indexOf(':');
|
|
54
|
+
const kind = colon === -1 ? pattern : pattern.slice(0, colon);
|
|
55
|
+
const title = colon === -1 ? null : pattern.slice(colon + 1);
|
|
56
|
+
if (kind !== '*' && !TOOL_KINDS.includes(kind)) fail(`${where} names unknown tool kind "${kind}"`);
|
|
57
|
+
if (title !== null && !title) fail(`${where} has an empty title glob`);
|
|
58
|
+
return Object.freeze({ pattern, kind, title: title === null ? null : globToRegExp(title) });
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const validateCredential = (credential, index, seen) => {
|
|
62
|
+
const where = `credentials[${index}]`;
|
|
63
|
+
if (!isPlainObject(credential)) fail(`${where} must be an object`);
|
|
64
|
+
refuseUnknownKeys(credential, ['name', 'from', 'ref'], `${where}.`);
|
|
65
|
+
const { name, from, ref } = credential;
|
|
66
|
+
if (typeof name !== 'string' || !ENV_NAME.test(name)) fail(`${where}.name must be an ENV_NAME (A-Z, 0-9, _)`);
|
|
67
|
+
if (RESERVED_NAME.test(name)) fail(`${where}.name ${name} is reserved for the run env`);
|
|
68
|
+
if (seen.has(name)) fail(`${where}.name ${name} is granted twice`);
|
|
69
|
+
seen.add(name);
|
|
70
|
+
if (!CREDENTIAL_SOURCES.includes(from)) fail(`${where}.from must be one of ${CREDENTIAL_SOURCES.join(', ')}`);
|
|
71
|
+
if (ref !== undefined && (typeof ref !== 'string' || !ref)) fail(`${where}.ref must be a non-empty string`);
|
|
72
|
+
if (from === 'env' && ref !== undefined && !ENV_NAME.test(ref)) fail(`${where}.ref must be an ENV_NAME for from: env`);
|
|
73
|
+
if (from === 'file' && (ref === undefined || !ref.startsWith('/'))) fail(`${where}.ref must be an absolute path for from: file`);
|
|
74
|
+
return Object.freeze(ref === undefined ? { name, from } : { name, from, ref });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Validate and freeze a grant. Throws on the first problem; never repairs one. */
|
|
78
|
+
export const validateGrant = (grant) => {
|
|
79
|
+
if (grant && grant[VALIDATED]) return grant;
|
|
80
|
+
if (!isPlainObject(grant)) fail('must be an object');
|
|
81
|
+
if ('network' in grant) fail('network rules are not enforced yet; remove the field rather than rely on it');
|
|
82
|
+
refuseUnknownKeys(grant, ['credentials', 'tools'], '');
|
|
83
|
+
const credentials = grant.credentials ?? [];
|
|
84
|
+
if (!Array.isArray(credentials)) fail('credentials must be an array');
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const tools = grant.tools ?? {};
|
|
87
|
+
if (!isPlainObject(tools)) fail('tools must be an object');
|
|
88
|
+
refuseUnknownKeys(tools, RULE_LISTS, 'tools.');
|
|
89
|
+
const rules = {};
|
|
90
|
+
for (const list of RULE_LISTS) {
|
|
91
|
+
const patterns = tools[list] ?? [];
|
|
92
|
+
if (!Array.isArray(patterns)) fail(`tools.${list} must be an array`);
|
|
93
|
+
rules[list] = Object.freeze(patterns.map((pattern, i) => parsePattern(pattern, `tools.${list}[${i}]`)));
|
|
94
|
+
}
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
[VALIDATED]: true,
|
|
97
|
+
credentials: Object.freeze(credentials.map((c, i) => validateCredential(c, i, seen))),
|
|
98
|
+
tools: Object.freeze(rules),
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const matches = (parsed, kind, title) => (parsed.kind === '*' || parsed.kind === kind)
|
|
102
|
+
&& (parsed.title === null || parsed.title.test(title));
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Which rule decides this tool call. ACP makes `kind` optional and means
|
|
106
|
+
* `other` when it is absent, so that is what an unknown or missing kind is.
|
|
107
|
+
* Returns { decision: 'allow'|'ask'|'deny', rule: { list, pattern } }.
|
|
108
|
+
*/
|
|
109
|
+
export const matchToolCall = (grant, toolCall = {}) => {
|
|
110
|
+
const valid = validateGrant(grant);
|
|
111
|
+
const kind = TOOL_KINDS.includes(toolCall?.kind) ? toolCall.kind : 'other';
|
|
112
|
+
const title = typeof toolCall?.title === 'string' ? toolCall.title : '';
|
|
113
|
+
for (const list of RULE_LISTS) {
|
|
114
|
+
const hit = valid.tools[list].find((parsed) => matches(parsed, kind, title));
|
|
115
|
+
if (hit) return { decision: list, rule: { list, pattern: hit.pattern } };
|
|
116
|
+
}
|
|
117
|
+
return { decision: 'deny', rule: { list: 'default', pattern: null } };
|
|
118
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a grant into ctx.decidePermission: the answer to ACP's
|
|
3
|
+
* session/request_permission, decided per tool call.
|
|
4
|
+
*
|
|
5
|
+
* allow → the allow_once option
|
|
6
|
+
* deny → the reject_once option, or cancelled when there is none
|
|
7
|
+
* ask → approve({ toolCall, grantRule }) from the host. Only an answer
|
|
8
|
+
* that says who gave it decides:
|
|
9
|
+
* { approved: true | false, by: <user id>, role, decisionCardId? },
|
|
10
|
+
* with `role` an owner role. Anything else (a bare true, 'yes',
|
|
11
|
+
* {approved: 1}, an answer naming nobody, a throw, no answer in
|
|
12
|
+
* time) is a failed ask, and the policy's default, deny, decides.
|
|
13
|
+
* An answer whose role is `pod-member` decides nothing (ADR-033 D5):
|
|
14
|
+
* the call stays parked, and approve is called again with
|
|
15
|
+
* `refused: { by, role: 'pod-member', reason: 'not_owner' }` so the
|
|
16
|
+
* card can show why and wait for the owner.
|
|
17
|
+
*
|
|
18
|
+
* allow_always and reject_always are never chosen: either would be remembered
|
|
19
|
+
* by the agent past this grant, so the next call would skip the check.
|
|
20
|
+
* An allow rule with no allow_once option therefore denies.
|
|
21
|
+
*/
|
|
22
|
+
import { traceOf } from '../trace/events.js';
|
|
23
|
+
import { argsDigestOf, PARKED_CALL_ROLES, isUserId, policyActorId } from '../record/format.js';
|
|
24
|
+
import { matchToolCall, validateGrant } from './grant.js';
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_APPROVE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
27
|
+
// An approver that keeps handing back pod-member answers is broken; after this
|
|
28
|
+
// many the ask fails, and the policy's default decides, as for any broken approver.
|
|
29
|
+
export const MAX_REFUSED_ANSWERS = 10;
|
|
30
|
+
|
|
31
|
+
const optionOfKind = (options, kind) => (Array.isArray(options) ? options : [])
|
|
32
|
+
.find((option) => option && option.kind === kind && typeof option.optionId === 'string');
|
|
33
|
+
|
|
34
|
+
const allowAnswer = (options) => {
|
|
35
|
+
const option = optionOfKind(options, 'allow_once');
|
|
36
|
+
return option ? { outcome: 'selected', optionId: option.optionId, kind: option.kind } : null;
|
|
37
|
+
};
|
|
38
|
+
const denyAnswer = (options) => {
|
|
39
|
+
const option = optionOfKind(options, 'reject_once');
|
|
40
|
+
return option ? { outcome: 'selected', optionId: option.optionId, kind: option.kind } : { outcome: 'cancelled' };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
44
|
+
const isText = (value) => typeof value === 'string' && value.length > 0;
|
|
45
|
+
|
|
46
|
+
// The roles a person can answer a parked call as; `policy` is not a person.
|
|
47
|
+
const ANSWER_ROLES = PARKED_CALL_ROLES.filter((role) => role !== 'policy');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What an approver's answer says. It approves or rejects only when it is an
|
|
51
|
+
* object whose `approved` is exactly true or false and that names a user id
|
|
52
|
+
* and an owner role. Any other answer approves nothing: it is a failed ask,
|
|
53
|
+
* so the record never carries a decision nobody can be held to.
|
|
54
|
+
*/
|
|
55
|
+
const readAnswer = (answer) => {
|
|
56
|
+
if (!isPlainObject(answer) || typeof answer.approved !== 'boolean') return { verdict: 'failed' };
|
|
57
|
+
if (!isUserId(answer.by) || !ANSWER_ROLES.includes(answer.role)) return { verdict: 'failed' };
|
|
58
|
+
return {
|
|
59
|
+
verdict: answer.approved ? 'approved' : 'rejected',
|
|
60
|
+
by: answer.by,
|
|
61
|
+
role: answer.role,
|
|
62
|
+
...(isText(answer.decisionCardId) ? { decisionCardId: answer.decisionCardId } : {}),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const isPodMemberAnswer = (answer) => isPlainObject(answer) && answer.role === 'pod-member';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wait for the host's approval, bounded. Resolves { verdict, by?, role?,
|
|
70
|
+
* decisionCardId? }, verdict 'approved' | 'rejected' | 'timed_out' | 'failed'
|
|
71
|
+
* | 'aborted'; never rejects, so a broken approver becomes a denial rather
|
|
72
|
+
* than a hung or crashed turn.
|
|
73
|
+
*/
|
|
74
|
+
const awaitApproval = (approve, input, { timeoutMs, signal }) => new Promise((done) => {
|
|
75
|
+
if (typeof approve !== 'function') { done({ verdict: 'failed' }); return; }
|
|
76
|
+
if (signal?.aborted) { done({ verdict: 'aborted' }); return; }
|
|
77
|
+
let finished = false;
|
|
78
|
+
const finish = (verdict) => {
|
|
79
|
+
if (finished) return;
|
|
80
|
+
finished = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
83
|
+
done(verdict);
|
|
84
|
+
};
|
|
85
|
+
const onAbort = () => finish({ verdict: 'aborted' });
|
|
86
|
+
const timer = setTimeout(() => finish({ verdict: 'timed_out' }), timeoutMs);
|
|
87
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
88
|
+
let refusals = 0;
|
|
89
|
+
const ask = (current) => Promise.resolve()
|
|
90
|
+
.then(() => approve(current))
|
|
91
|
+
.then((answer) => {
|
|
92
|
+
if (finished) return;
|
|
93
|
+
if (!isPodMemberAnswer(answer)) { finish(readAnswer(answer)); return; }
|
|
94
|
+
// A pod member may not decide a parked call: the call stays parked and
|
|
95
|
+
// the owner is asked again, with the refusal, inside the same timeout.
|
|
96
|
+
refusals += 1;
|
|
97
|
+
if (refusals > MAX_REFUSED_ANSWERS) { finish({ verdict: 'failed' }); return; }
|
|
98
|
+
const refused = { by: isUserId(answer.by) ? answer.by : null, role: 'pod-member', reason: 'not_owner' };
|
|
99
|
+
setTimeout(() => { if (!finished) ask({ ...input, refused }); }, 0);
|
|
100
|
+
}, () => finish({ verdict: 'failed' }));
|
|
101
|
+
ask(input);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Who decided an ask, for the record. A person who answered is named by the
|
|
106
|
+
* approver; anything else (no answer in time, an answer naming nobody, a
|
|
107
|
+
* broken approver, a cancelled run) means the policy's default decided.
|
|
108
|
+
* Rule-decided calls carry no decidedBy here; the record writer fills in
|
|
109
|
+
* "policy" for any decision whose rule list is not `ask`.
|
|
110
|
+
*/
|
|
111
|
+
const decider = (approval) => {
|
|
112
|
+
if (approval.verdict === 'approved' || approval.verdict === 'rejected') {
|
|
113
|
+
return {
|
|
114
|
+
decidedBy: approval.by,
|
|
115
|
+
decidedByRole: approval.role,
|
|
116
|
+
...(approval.decisionCardId ? { decisionCardId: approval.decisionCardId } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { decidedBy: 'policy', decidedByRole: 'policy' };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const isAsker = (value) => isPlainObject(value) && isUserId(value.id) && PARKED_CALL_ROLES.includes(value.role);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Who an ask goes to, for permission.requested. `askedOf` is {id, role} or
|
|
126
|
+
* ({toolCall, grantRule}) => {id, role, decisionCardId?}. A call a
|
|
127
|
+
* rule decides is asked of the grant's policy. A function that throws or
|
|
128
|
+
* answers malformed is recorded as asked of the policy too: the record does
|
|
129
|
+
* not name a person it cannot vouch for.
|
|
130
|
+
*/
|
|
131
|
+
const askOf = (askedOf, input, policy) => {
|
|
132
|
+
if (input.grantRule?.list !== 'ask') return policy;
|
|
133
|
+
let answer = askedOf;
|
|
134
|
+
if (typeof askedOf === 'function') {
|
|
135
|
+
try { answer = askedOf(input); } catch { answer = null; }
|
|
136
|
+
}
|
|
137
|
+
if (!isAsker(answer)) return policy;
|
|
138
|
+
return {
|
|
139
|
+
id: answer.id,
|
|
140
|
+
role: answer.role,
|
|
141
|
+
...(isText(answer.decisionCardId) ? { decisionCardId: answer.decisionCardId } : {}),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* createPermissionPolicy(grant, { approve?, trace?, approveTimeoutMs?, signal?, grantId?, askedOf? })
|
|
147
|
+
* → (request) => Promise<{outcome:'selected', optionId} | {outcome:'cancelled'}>
|
|
148
|
+
*
|
|
149
|
+
* `signal` is the run's AbortSignal: a cancelled run answers a pending ask
|
|
150
|
+
* with cancelled, which is what ACP asks of a client after session/cancel.
|
|
151
|
+
* `grantId` is recorded on each decision, to tie it to its grant.issued.
|
|
152
|
+
* With `askedOf` (above), each request is recorded first as
|
|
153
|
+
* permission.requested: the question (the tool call's title), who it is
|
|
154
|
+
* asked of, and the card it rides on. The approver receives that card id.
|
|
155
|
+
* When the tool call carries `rawInput`, both entries carry its argsDigest.
|
|
156
|
+
* Given a `trace`, the returned function has `recordsPermissions === true`.
|
|
157
|
+
*/
|
|
158
|
+
export const createPermissionPolicy = (grant, {
|
|
159
|
+
approve, trace, approveTimeoutMs = DEFAULT_APPROVE_TIMEOUT_MS, signal, grantId, askedOf,
|
|
160
|
+
} = {}) => {
|
|
161
|
+
const valid = validateGrant(grant);
|
|
162
|
+
const hook = traceOf({ trace });
|
|
163
|
+
if (askedOf !== undefined && typeof askedOf !== 'function' && !isAsker(askedOf)) {
|
|
164
|
+
throw new Error('askedOf must be {id: <user id>, role: <decidedByRole>} or a function returning one');
|
|
165
|
+
}
|
|
166
|
+
const policy = { id: policyActorId(grantId), role: 'policy' };
|
|
167
|
+
const decide = async (request = {}) => {
|
|
168
|
+
const toolCall = request?.toolCall || {};
|
|
169
|
+
const options = request?.options;
|
|
170
|
+
const { decision, rule } = matchToolCall(valid, toolCall);
|
|
171
|
+
const toolCallId = typeof toolCall.toolCallId === 'string' ? toolCall.toolCallId : null;
|
|
172
|
+
const argsDigest = argsDigestOf(toolCall.rawInput);
|
|
173
|
+
let asker = null;
|
|
174
|
+
if (askedOf !== undefined) {
|
|
175
|
+
asker = askOf(askedOf, { toolCall, grantRule: rule }, policy);
|
|
176
|
+
const fallback = `${isText(toolCall.kind) ? toolCall.kind : 'tool'} call`;
|
|
177
|
+
hook.emit('permission.requested', {
|
|
178
|
+
toolCallId,
|
|
179
|
+
options: (Array.isArray(options) ? options : []).filter(isPlainObject).map(({ optionId, kind }) => ({ optionId, kind })),
|
|
180
|
+
question: isText(toolCall.title) ? toolCall.title : fallback,
|
|
181
|
+
askedOf: { id: asker.id, role: asker.role },
|
|
182
|
+
...(grantId ? { grantId } : {}),
|
|
183
|
+
...(asker.decisionCardId ? { decisionCardId: asker.decisionCardId } : {}),
|
|
184
|
+
...(argsDigest ? { argsDigest } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
let approval;
|
|
188
|
+
let answer;
|
|
189
|
+
if (decision === 'ask') {
|
|
190
|
+
const input = { toolCall, grantRule: rule, ...(asker?.decisionCardId ? { decisionCardId: asker.decisionCardId } : {}) };
|
|
191
|
+
approval = await awaitApproval(approve, input, { timeoutMs: approveTimeoutMs, signal });
|
|
192
|
+
if (approval.verdict === 'aborted') answer = { outcome: 'cancelled' };
|
|
193
|
+
else answer = approval.verdict === 'approved' ? allowAnswer(options) : null;
|
|
194
|
+
} else if (decision === 'allow') {
|
|
195
|
+
answer = allowAnswer(options);
|
|
196
|
+
}
|
|
197
|
+
if (!answer) answer = denyAnswer(options);
|
|
198
|
+
hook.emit('permission.decided', {
|
|
199
|
+
outcome: answer.outcome,
|
|
200
|
+
...(answer.optionId ? { optionId: answer.optionId, kind: answer.kind } : {}),
|
|
201
|
+
toolCallId,
|
|
202
|
+
rule,
|
|
203
|
+
...(approval ? { approval: approval.verdict, ...decider(approval) } : {}),
|
|
204
|
+
...(grantId ? { grantId } : {}),
|
|
205
|
+
...(argsDigest ? { argsDigest } : {}),
|
|
206
|
+
});
|
|
207
|
+
return answer.outcome === 'selected' ? { outcome: 'selected', optionId: answer.optionId } : { outcome: 'cancelled' };
|
|
208
|
+
};
|
|
209
|
+
// With a trace this writes both permission entries itself, so an adapter
|
|
210
|
+
// handed it as ctx.decidePermission must not write them again.
|
|
211
|
+
if (trace) Object.defineProperty(decide, 'recordsPermissions', { value: true });
|
|
212
|
+
return decide;
|
|
213
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.174",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
@@ -32,11 +32,16 @@
|
|
|
32
32
|
"lint:fix": "eslint src --ext .js --fix",
|
|
33
33
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
34
34
|
"test:temp-cleanup": "node scripts/verify-temp-cleanup.mjs",
|
|
35
|
+
"prepack": "node scripts/require-bundled-seats.mjs",
|
|
35
36
|
"prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
39
|
+
"@commonlyai/seats": "file:../packages/seats",
|
|
38
40
|
"commander": "^12.0.0"
|
|
39
41
|
},
|
|
42
|
+
"bundleDependencies": [
|
|
43
|
+
"@commonlyai/seats"
|
|
44
|
+
],
|
|
40
45
|
"devDependencies": {
|
|
41
46
|
"eslint": "^8.56.0",
|
|
42
47
|
"jest": "^29.7.0"
|
package/src/commands/agent.js
CHANGED
|
@@ -22,7 +22,6 @@ import { createHash } from 'crypto';
|
|
|
22
22
|
|
|
23
23
|
import { requireSeatModel } from '../lib/adapters/seat-model.js';
|
|
24
24
|
import { createClient } from '../lib/api.js';
|
|
25
|
-
import { copyHandoffMemory } from '../lib/handoff-memory.js';
|
|
26
25
|
import {
|
|
27
26
|
buildSpawnId,
|
|
28
27
|
createSpawnCredentialLease,
|
|
@@ -83,6 +82,7 @@ import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
|
83
82
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
84
83
|
import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
|
|
85
84
|
import { prepareMcpBeforeFirstPoll } from '../lib/mcp-first-run.js';
|
|
85
|
+
import { createSeatStop } from '../lib/seat-stop.js';
|
|
86
86
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
87
87
|
import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
|
|
88
88
|
import {
|
|
@@ -987,6 +987,13 @@ export const performAttach = async ({
|
|
|
987
987
|
// server via legacy normalization).
|
|
988
988
|
const runtimeType = adapter.runtimeType || adapter.name;
|
|
989
989
|
|
|
990
|
+
if (memoryFrom) {
|
|
991
|
+
const capability = await client.get('/api/registry/install/memory-handoff');
|
|
992
|
+
if (capability?.installTimeMemoryHandoff !== true) {
|
|
993
|
+
throw new Error('Server does not support install-time memory handoff; upgrade the server before attaching');
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
990
997
|
// Idempotent publish — already-published is expected and fine. Install will
|
|
991
998
|
// surface the real error if the manifest is truly unusable.
|
|
992
999
|
try {
|
|
@@ -1010,7 +1017,9 @@ export const performAttach = async ({
|
|
|
1010
1017
|
// on config.environment so cross-driver implementations can realize it
|
|
1011
1018
|
// server-side without re-reading the user's file.
|
|
1012
1019
|
const installResult = await client.post('/api/registry/install', {
|
|
1013
|
-
...(memoryFrom ? {
|
|
1020
|
+
...(memoryFrom ? {
|
|
1021
|
+
resumeExisting: true, memoryFrom: { agentName: memoryFrom, instanceId: memoryInstance },
|
|
1022
|
+
} : {}),
|
|
1014
1023
|
agentName,
|
|
1015
1024
|
podId,
|
|
1016
1025
|
displayName: displayName || agentName,
|
|
@@ -1034,10 +1043,8 @@ export const performAttach = async ({
|
|
|
1034
1043
|
const instanceId = installation.instanceId || 'default';
|
|
1035
1044
|
|
|
1036
1045
|
if (memoryFrom) {
|
|
1037
|
-
const memoryCopy =
|
|
1038
|
-
|
|
1039
|
-
targetAgentName: installation.agentName, targetInstanceId: instanceId,
|
|
1040
|
-
});
|
|
1046
|
+
const memoryCopy = installResult.memoryCopy;
|
|
1047
|
+
if (!memoryCopy) throw new Error('Server does not support install-time memory handoff; upgrade the server before attaching');
|
|
1041
1048
|
log(memoryCopy.reason === 'source-empty'
|
|
1042
1049
|
? 'hosted agent has no long-term memory to copy'
|
|
1043
1050
|
: 'hosted long-term memory is present in the local agent');
|
|
@@ -3830,12 +3837,16 @@ Docs:
|
|
|
3830
3837
|
// SIGTERM is the daemon's idle-boundary handoff signal. `stop()` stops
|
|
3831
3838
|
// future polls but lets the in-flight turn finish and ack before the
|
|
3832
3839
|
// event loop drains; exiting here would cut a model turn in half.
|
|
3833
|
-
|
|
3840
|
+
// seat-stop also arms the supervised-group deadline before launchd's
|
|
3841
|
+
// SIGKILL, and kills any group still live when the process exits.
|
|
3842
|
+
const seatStop = createSeatStop({ name, stop });
|
|
3843
|
+
const requestStop = (signal) => {
|
|
3834
3844
|
console.log(`\n${stamp()} [${name}] stopping after the current turn...`);
|
|
3835
|
-
|
|
3845
|
+
seatStop.requestStop(signal);
|
|
3836
3846
|
};
|
|
3837
3847
|
process.on('SIGINT', requestStop);
|
|
3838
3848
|
process.on('SIGTERM', requestStop);
|
|
3849
|
+
process.once('exit', seatStop.onExit);
|
|
3839
3850
|
});
|
|
3840
3851
|
|
|
3841
3852
|
// ── detach (ADR-005) ──────────────────────────────────────────────────────
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
2
|
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
|
+
import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
|
|
3
4
|
/**
|
|
4
5
|
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
5
6
|
*
|
|
@@ -301,12 +302,14 @@ const publicSkillPromptArgs = async (skillSources) => {
|
|
|
301
302
|
return { args: text ? ['--append-system-prompt', text] : [], included };
|
|
302
303
|
};
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
// `ownGroup` is set only under the run supervisor, which kills the child's
|
|
306
|
+
// whole group; unsupervised, the spawn options are exactly what they were.
|
|
307
|
+
const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false }) => new Promise((resolve, reject) => {
|
|
308
|
+
const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
|
|
306
309
|
cwd,
|
|
307
310
|
env,
|
|
308
311
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
309
|
-
});
|
|
312
|
+
}, { ownGroup });
|
|
310
313
|
observeSpawn(proc, onSpawn, controls);
|
|
311
314
|
let stdout = '';
|
|
312
315
|
let stderr = '';
|
|
@@ -920,6 +923,7 @@ export default {
|
|
|
920
923
|
// the value a tail can echo back.
|
|
921
924
|
credentials: spawnCredentials(spawnCtx),
|
|
922
925
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
|
|
926
|
+
ownGroup: wantsOwnProcessGroup(ctx),
|
|
923
927
|
});
|
|
924
928
|
// TASK-440: the context this turn left, read from the transcript the
|
|
925
929
|
// child wrote under its own env (an isolated HOME for public seats).
|
|
@@ -974,6 +978,7 @@ export default {
|
|
|
974
978
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
975
979
|
credentials: spawnCredentials(retryCtx),
|
|
976
980
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
981
|
+
ownGroup: wantsOwnProcessGroup(ctx),
|
|
977
982
|
});
|
|
978
983
|
const contextTokens = claudeContextTokens({
|
|
979
984
|
cwd: ctx.cwd, sessionId: freshId,
|