@commonlyai/cli 0.1.173 → 0.1.175

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.
Files changed (29) hide show
  1. package/node_modules/@commonlyai/seats/README.md +11 -0
  2. package/node_modules/@commonlyai/seats/package.json +22 -0
  3. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +206 -0
  4. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  5. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  6. package/node_modules/@commonlyai/seats/src/failures.js +38 -0
  7. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  8. package/node_modules/@commonlyai/seats/src/process-group.js +261 -0
  9. package/node_modules/@commonlyai/seats/src/record/format.js +635 -0
  10. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  11. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  12. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  13. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  14. package/node_modules/@commonlyai/seats/src/trace/events.js +45 -0
  15. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  16. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +362 -0
  17. package/node_modules/@commonlyai/seats/src/valet/env.js +164 -0
  18. package/node_modules/@commonlyai/seats/src/valet/grant.js +118 -0
  19. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  20. package/package.json +6 -1
  21. package/src/commands/agent.js +21 -3
  22. package/src/lib/adapters/claude.js +8 -3
  23. package/src/lib/adapters/codex.js +5 -2
  24. package/src/lib/adapters/hermes.js +13 -194
  25. package/src/lib/adapters/seat-model.js +5 -12
  26. package/src/lib/adapters/stub.js +1 -24
  27. package/src/lib/runtime-observation.js +3 -27
  28. package/src/lib/seat-stop.js +96 -0
  29. package/src/lib/upstream-refusal.js +1 -354
@@ -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.173",
3
+ "version": "0.1.175",
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"
@@ -82,6 +82,7 @@ import { detectBwrap } from '../lib/sandbox/bwrap.js';
82
82
  import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
83
83
  import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
84
84
  import { prepareMcpBeforeFirstPoll } from '../lib/mcp-first-run.js';
85
+ import { createSeatStop } from '../lib/seat-stop.js';
85
86
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
86
87
  import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
87
88
  import {
@@ -1145,6 +1146,17 @@ export const runMemoryImport = async ({
1145
1146
 
1146
1147
  // ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
1147
1148
 
1149
+ // TASK-293, and the rule AX entry 43 earned: a frame that says "otherwise
1150
+ // return NO_REPLY" must also say what the otherwise costs. Silence here is an
1151
+ // exact match (`silentReply` in runTurn), so a seat that checkpointed and then
1152
+ // ended its turn on a summary of doing nothing had the summary posted — four
1153
+ // times in 24 h, from two seats. No wrapper-side classifier guesses which
1154
+ // summaries were meant for nobody; the consequence goes where the instruction
1155
+ // is. Every prompt whose final text the wrapper posts to the pod carries it.
1156
+ // agent.ask does not: its final text goes only to the asking agent.
1157
+ const FINAL_TEXT_IS_POSTED = 'Your final text is posted to the pod unless it is exactly NO_REPLY. '
1158
+ + 'If you did nothing, or already posted through a tool, end with NO_REPLY alone, with no summary.';
1159
+
1148
1160
  const extractPrompt = (event) => {
1149
1161
  const p = event.payload || {};
1150
1162
  if (PROMPT_EVENT_TYPES.has(event.type)) {
@@ -1202,6 +1214,7 @@ const extractPrompt = (event) => {
1202
1214
  '',
1203
1215
  'Use this answer to continue the work you were doing.',
1204
1216
  'Only post a concise pod update if a human needs it; otherwise return NO_REPLY.',
1217
+ FINAL_TEXT_IS_POSTED,
1205
1218
  ].join('\n');
1206
1219
  }
1207
1220
  if (event.type === 'decision.ruled') {
@@ -2248,7 +2261,8 @@ export const performRun = ({
2248
2261
  ...(omitted > 0
2249
2262
  ? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`]
2250
2263
  : []),
2251
- 'Do not narrate the inbox. Post only an action or a materially useful response; otherwise return NO_REPLY.',
2264
+ 'Do not narrate the inbox. Post only an action or a materially useful response; otherwise return NO_REPLY. '
2265
+ + FINAL_TEXT_IS_POSTED,
2252
2266
  ].join('\n\n');
2253
2267
  };
2254
2268
 
@@ -3836,12 +3850,16 @@ Docs:
3836
3850
  // SIGTERM is the daemon's idle-boundary handoff signal. `stop()` stops
3837
3851
  // future polls but lets the in-flight turn finish and ack before the
3838
3852
  // event loop drains; exiting here would cut a model turn in half.
3839
- const requestStop = () => {
3853
+ // seat-stop also arms the supervised-group deadline before launchd's
3854
+ // SIGKILL, and kills any group still live when the process exits.
3855
+ const seatStop = createSeatStop({ name, stop });
3856
+ const requestStop = (signal) => {
3840
3857
  console.log(`\n${stamp()} [${name}] stopping after the current turn...`);
3841
- stop();
3858
+ seatStop.requestStop(signal);
3842
3859
  };
3843
3860
  process.on('SIGINT', requestStop);
3844
3861
  process.on('SIGTERM', requestStop);
3862
+ process.once('exit', seatStop.onExit);
3845
3863
  });
3846
3864
 
3847
3865
  // ── 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
- const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
305
- const proc = spawnImpl(cmd, args, {
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,
@@ -1,5 +1,6 @@
1
1
  import { codexRuntimeInventory } 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
  * codex adapter — wraps the local `codex` CLI as a Commonly agent.
5
6
  *
@@ -572,14 +573,15 @@ const CODEX_PROVIDER_KEY_ENVS = [
572
573
  'CODEX_API_KEY',
573
574
  ];
574
575
 
575
- const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {} }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
576
+ // `ownGroup`: see runClaude in claude.js.
577
+ const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
576
578
  // defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
577
579
  // `Reading additional input from stdin...` because it sees an open pipe
578
580
  // and waits for input that never arrives. Interactive runs are fine because
579
581
  // codex detects a TTY and uses the argv prompt directly. Setting stdin to
580
582
  // `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
581
583
  // argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
582
- const proc = spawnImpl('codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
584
+ const proc = spawnInOwnGroup(spawnImpl, 'codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
583
585
  observeSpawn(proc, onSpawn, controls);
584
586
  let stderr = '';
585
587
  let timedOut = false;
@@ -745,6 +747,7 @@ export default {
745
747
  // nothing left to compare a tail against.
746
748
  credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
747
749
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
750
+ ownGroup: wantsOwnProcessGroup(ctx),
748
751
  });
749
752
 
750
753
  let text = '';