@commonlyai/cli 0.1.68 → 0.1.70
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 +1 -1
- package/src/commands/agent.js +63 -26
- package/src/lib/claim-outcome.js +120 -0
- package/src/lib/enforcement.js +30 -2
- package/src/lib/environment.js +16 -0
- package/src/lib/grant-broker-guard.js +3 -10
- package/src/lib/sandbox/mode.js +14 -7
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -34,11 +34,13 @@ import { detectMemorySources, composeImport, importMemory } from '../lib/memory-
|
|
|
34
34
|
import { detectSkills, importSkills } from '../lib/skills-import.js';
|
|
35
35
|
import { parseEnvironmentFile, resolveWorkspace, validateEnvironmentSpec } from '../lib/environment.js';
|
|
36
36
|
import { ADAPTERS_WITH_DEFAULT_MCP, defaultMcpServers } from '../lib/default-environment.js';
|
|
37
|
+
import { withholdGrantBroker } from '../lib/grant-broker-guard.js';
|
|
37
38
|
import {
|
|
38
39
|
FOCUS_FRAME_MAX_CODE_POINTS,
|
|
39
40
|
formatPodFocusFrame,
|
|
40
41
|
readPodFocus,
|
|
41
42
|
} from '../lib/pod-focus.js';
|
|
43
|
+
import { claimReleaseFor, ackResultFor } from '../lib/claim-outcome.js';
|
|
42
44
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
43
45
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
44
46
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
@@ -871,6 +873,27 @@ export const performRun = ({
|
|
|
871
873
|
sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
872
874
|
}) => {
|
|
873
875
|
const client = createClient({ instance: instanceUrl, token });
|
|
876
|
+
|
|
877
|
+
// THE SEAT'S OWN RECORD IS THE ONLY DECLARATION THERE IS. The broker reaches a
|
|
878
|
+
// seat as an injected MCP entry written by hand into its environment — no
|
|
879
|
+
// server projection puts it there (`GRANT_BROKER_URL` is imported by one
|
|
880
|
+
// route, the daemon assignment, and that one already refuses). So a seat run
|
|
881
|
+
// by `commonly agent run` under launchd spawns the entry verbatim, and the
|
|
882
|
+
// confinement it promises is declared in a different field (`sandbox`), which
|
|
883
|
+
// can disagree. The daemon half of this refusal already runs
|
|
884
|
+
// (`daemon-supervisor.js`); this is the same predicate at the only other site
|
|
885
|
+
// that can see the record — same code, so one refusal with two emitters.
|
|
886
|
+
//
|
|
887
|
+
// IN MEMORY, RECORD UNTOUCHED (wren 71641): the withheld environment is what
|
|
888
|
+
// the adapter spawns with, and the stored record keeps its broker, so the
|
|
889
|
+
// daemon stays the layer that judges a host it knows. Applied once for the
|
|
890
|
+
// run rather than per turn: `agent run` reads its record once at boot.
|
|
891
|
+
const seatEnvironment = withholdGrantBroker(environment, adapter?.name, {
|
|
892
|
+
instanceUrl,
|
|
893
|
+
onRefuse: (refusal, names) => log(
|
|
894
|
+
`${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`,
|
|
895
|
+
),
|
|
896
|
+
});
|
|
874
897
|
let running = true;
|
|
875
898
|
// Stop-after-N-auth-failures: without this, a revoked token leaves the
|
|
876
899
|
// poller hammering 401s forever at 60s backoff — invisible to the user.
|
|
@@ -1045,6 +1068,7 @@ export const performRun = ({
|
|
|
1045
1068
|
// goes in a sibling field instead of being spelled into the reason.
|
|
1046
1069
|
return {
|
|
1047
1070
|
outcome: 'no_action',
|
|
1071
|
+
refused: { reason: 'cascade-cap' },
|
|
1048
1072
|
reason: 'cascade-cap',
|
|
1049
1073
|
details: {
|
|
1050
1074
|
// The id of what was dropped. `reason` and `streak` say a refusal
|
|
@@ -1139,16 +1163,19 @@ export const performRun = ({
|
|
|
1139
1163
|
} finally {
|
|
1140
1164
|
// A silent, normally completed human wake is an explicit decline, not
|
|
1141
1165
|
// a successful answer. Tell the kernel so it can hand the message to
|
|
1142
|
-
// exactly one remaining original listener.
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1166
|
+
// exactly one remaining original listener. A refusal — the upstream
|
|
1167
|
+
// route down, or the server refusing the post — is NAMED (TASK-099): it
|
|
1168
|
+
// carries its class so the kernel's record can tell "answered" from
|
|
1169
|
+
// "never ran", and on a human wake the kernel hands it on exactly as it
|
|
1170
|
+
// does a decline, because a failure local to THIS seat must not make a
|
|
1171
|
+
// human's message disappear. A thrown spawn keeps the legacy release, so
|
|
1172
|
+
// normal at-least-once redelivery still happens after an infrastructure
|
|
1173
|
+
// failure. One decision point, shared with the batch path — see
|
|
1174
|
+
// lib/claim-outcome.js.
|
|
1175
|
+
const release = claimReleaseFor(event, turnResult);
|
|
1176
|
+
await claimKeeper?.release(release.outcome, {
|
|
1177
|
+
reason: release.reason, status: release.status,
|
|
1178
|
+
});
|
|
1152
1179
|
}
|
|
1153
1180
|
};
|
|
1154
1181
|
|
|
@@ -1190,7 +1217,7 @@ export const performRun = ({
|
|
|
1190
1217
|
cwd: agentCwd,
|
|
1191
1218
|
env: process.env,
|
|
1192
1219
|
memoryLongTerm,
|
|
1193
|
-
environment,
|
|
1220
|
+
environment: seatEnvironment,
|
|
1194
1221
|
// Runtime context the Claude/Codex adapters expose only to their
|
|
1195
1222
|
// per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders
|
|
1196
1223
|
// literal on disk and lets its native MCP parser expand them, so the
|
|
@@ -1397,6 +1424,7 @@ export const performRun = ({
|
|
|
1397
1424
|
if (deliveryRefusal) {
|
|
1398
1425
|
return {
|
|
1399
1426
|
outcome: 'no_action',
|
|
1427
|
+
refused: { reason: 'delivery-refused' },
|
|
1400
1428
|
reason: deliveryRefusal.reason,
|
|
1401
1429
|
details: {
|
|
1402
1430
|
mode: deliveryRefusal.mode,
|
|
@@ -1409,14 +1437,22 @@ export const performRun = ({
|
|
|
1409
1437
|
},
|
|
1410
1438
|
};
|
|
1411
1439
|
}
|
|
1412
|
-
// A refusal is not a decline
|
|
1413
|
-
// chose not to answer" and hands a human
|
|
1414
|
-
// nothing about the agent's choice happened
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1440
|
+
// A refusal is not a decline, and it is not a close either. `no_action`
|
|
1441
|
+
// with no reason means "the agent chose not to answer" and hands a human
|
|
1442
|
+
// wake to one remaining listener; nothing about the agent's choice happened
|
|
1443
|
+
// here — the model never ran — so the release says WHICH class of refusal
|
|
1444
|
+
// it was (TASK-099). The kernel routes from there: on a human wake the
|
|
1445
|
+
// message is handed to one remaining listener exactly as a decline is
|
|
1446
|
+
// (this seat's upstream route being down says nothing about theirs), and on
|
|
1447
|
+
// any other wake it is terminal. The free-text detail stays in the seat
|
|
1448
|
+
// log and the event ack; the kernel records the class.
|
|
1417
1449
|
if (!delivered && upstream) {
|
|
1418
1450
|
return {
|
|
1419
1451
|
outcome: 'no_action',
|
|
1452
|
+
refused: {
|
|
1453
|
+
reason: 'upstream-refused',
|
|
1454
|
+
...(Number.isInteger(upstream.status) ? { status: upstream.status } : {}),
|
|
1455
|
+
},
|
|
1420
1456
|
reason: `upstream-refused-${upstream.status}`,
|
|
1421
1457
|
details: {
|
|
1422
1458
|
status: upstream.status,
|
|
@@ -1461,6 +1497,7 @@ export const performRun = ({
|
|
|
1461
1497
|
|
|
1462
1498
|
const batchAdmissionResult = (event, admission) => ({
|
|
1463
1499
|
outcome: 'no_action',
|
|
1500
|
+
refused: { reason: 'cascade-cap' },
|
|
1464
1501
|
reason: 'cascade-cap',
|
|
1465
1502
|
details: {
|
|
1466
1503
|
messageId: event?.payload?.messageId || null,
|
|
@@ -1646,14 +1683,14 @@ export const performRun = ({
|
|
|
1646
1683
|
.filter((entry) => entry.claimKeeper)
|
|
1647
1684
|
.map(async (entry) => {
|
|
1648
1685
|
// Preserve ADR-018 D6.1 per binding message: a silent human
|
|
1649
|
-
// broadcast may be handed to one remaining listener,
|
|
1650
|
-
// other completed batch item
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
:
|
|
1656
|
-
|
|
1686
|
+
// broadcast may be handed to one remaining listener, a refusal
|
|
1687
|
+
// closes with its reason, and every other completed batch item
|
|
1688
|
+
// closes normally. Same decision point as the single-event path.
|
|
1689
|
+
// Same decision point as the single-event path.
|
|
1690
|
+
const release = claimReleaseFor(entry.event, turnResult);
|
|
1691
|
+
await entry.claimKeeper.release(release.outcome, {
|
|
1692
|
+
reason: release.reason, status: release.status,
|
|
1693
|
+
});
|
|
1657
1694
|
}));
|
|
1658
1695
|
}
|
|
1659
1696
|
};
|
|
@@ -1743,7 +1780,7 @@ export const performRun = ({
|
|
|
1743
1780
|
try {
|
|
1744
1781
|
const deliveryId = event.payload?.deliveryId;
|
|
1745
1782
|
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
1746
|
-
result: entry.result,
|
|
1783
|
+
result: ackResultFor(entry.result),
|
|
1747
1784
|
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
1748
1785
|
});
|
|
1749
1786
|
} catch (ackErr) {
|
|
@@ -1843,7 +1880,7 @@ export const performRun = ({
|
|
|
1843
1880
|
try {
|
|
1844
1881
|
const deliveryId = event.payload?.deliveryId;
|
|
1845
1882
|
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
1846
|
-
result,
|
|
1883
|
+
result: ackResultFor(result),
|
|
1847
1884
|
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
1848
1885
|
});
|
|
1849
1886
|
} catch (ackErr) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claimReleaseFor — the ONE decision about how a finished turn releases its
|
|
3
|
+
* ADR-018 attention claim.
|
|
4
|
+
*
|
|
5
|
+
* It lives here, extracted from two byte-identical copies in the run loop
|
|
6
|
+
* (single-event and batch release), for the reason a second copy is always a
|
|
7
|
+
* risk: the batch path is the one `message.posted` actually reaches, so a
|
|
8
|
+
* change applied to only one of them would pass review and do nothing.
|
|
9
|
+
*
|
|
10
|
+
* Three outcomes, and the KERNEL decides what each one does with the wake
|
|
11
|
+
* (TASK-099 ruling, corrected at 71194/71195/71210):
|
|
12
|
+
*
|
|
13
|
+
* - `refused` — the lease holder could not deliver. Nothing about the
|
|
14
|
+
* agent's CHOICE happened: the model never ran (an upstream
|
|
15
|
+
* 429/502/401, named by TASK-096) or the server refused the
|
|
16
|
+
* post. On a HUMAN wake the kernel hands the message to one
|
|
17
|
+
* remaining listener exactly as it does for a decline — a
|
|
18
|
+
* per-seat budget failure must not make a human's message
|
|
19
|
+
* disappear — and records the refuser so the chain stays
|
|
20
|
+
* finite. On any other wake it is terminal. The routing is
|
|
21
|
+
* the kernel's: this module only names the outcome.
|
|
22
|
+
* - `declined` — a silent, normally completed human broadcast. The agent
|
|
23
|
+
* chose not to answer, so the kernel hands it to exactly one
|
|
24
|
+
* remaining original listener.
|
|
25
|
+
* - `completed` — everything else that produced a result: a posted reply, or
|
|
26
|
+
* a skip with a reason (`no-prompt`, `claim-held`,
|
|
27
|
+
* `duplicate-delivery`) where nothing was refused.
|
|
28
|
+
*
|
|
29
|
+
* The refusal CLASS is an enum, not free text: `upstream-refused` (with the
|
|
30
|
+
* HTTP status), `cascade-cap`, `delivery-refused`. The kernel stores the class
|
|
31
|
+
* so refusals are countable, and rejects anything else with a 400. A producer
|
|
32
|
+
* that cannot classify itself maps to `delivery-refused` before sending — the
|
|
33
|
+
* mapping is here, in one place, rather than in each producer.
|
|
34
|
+
*
|
|
35
|
+
* A thrown turn returns no turnResult and releases with no outcome — the
|
|
36
|
+
* legacy holder-only DELETE whose at-least-once redelivery is the correct
|
|
37
|
+
* behaviour for a crashed spawn (the lid closed, the process died).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The classes the kernel accepts. Mirrors `REFUSAL_REASONS` in agentsRuntime. */
|
|
41
|
+
export const REFUSAL_REASONS = ['upstream-refused', 'cascade-cap', 'delivery-refused'];
|
|
42
|
+
|
|
43
|
+
/** What an unclassifiable refusal is recorded as, rather than a fourth class. */
|
|
44
|
+
export const DEFAULT_REFUSAL_REASON = 'delivery-refused';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The range the kernel accepts for a refusal's `status` — its
|
|
48
|
+
* `MIN_UPSTREAM_STATUS` / `MAX_UPSTREAM_STATUS` in `messageClaimService`.
|
|
49
|
+
* A number outside it is not a refusal status the kernel will record: it
|
|
50
|
+
* answers 400, and the 400 fallback in `enforcement.release` re-releases as
|
|
51
|
+
* `completed` — which is terminal. So forwarding an out-of-range number does
|
|
52
|
+
* not merely lose the number, it loses the handoff: an upstream refusal on a
|
|
53
|
+
* human wake would be recorded as a clean completion. The sender's rule has to
|
|
54
|
+
* be the receiver's rule, which is why the bound lives here as well as there.
|
|
55
|
+
*/
|
|
56
|
+
export const MIN_UPSTREAM_STATUS = 400;
|
|
57
|
+
export const MAX_UPSTREAM_STATUS = 599;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{type?: string, payload?: {senderIsHuman?: boolean}}} event
|
|
61
|
+
* @param {{refused?: {reason?: string, status?: number}|true, outcome?: string, reason?: string}|undefined} turnResult
|
|
62
|
+
* @returns {{outcome?: 'refused'|'declined'|'completed', reason?: string, status?: number}}
|
|
63
|
+
*/
|
|
64
|
+
export const claimReleaseFor = (event, turnResult) => {
|
|
65
|
+
if (!turnResult) return { outcome: undefined };
|
|
66
|
+
// `refused: true` is still honoured: it is what an older producer shape (and
|
|
67
|
+
// a third-party wrapper following TASK-096's docs) sends, and it means the
|
|
68
|
+
// same thing — `delivery-refused`, the class that claims the least.
|
|
69
|
+
if (turnResult.refused) {
|
|
70
|
+
const declared = typeof turnResult.refused === 'object' ? turnResult.refused : {};
|
|
71
|
+
const reason = REFUSAL_REASONS.includes(declared.reason)
|
|
72
|
+
? declared.reason
|
|
73
|
+
: DEFAULT_REFUSAL_REASON;
|
|
74
|
+
// The status is the instance of the class, and only the upstream class has
|
|
75
|
+
// one: a cap refusal or a refused post never saw an upstream HTTP status,
|
|
76
|
+
// so sending its number would put a field on the record that cannot mean
|
|
77
|
+
// what a reader would assume. It is also bounded by what the kernel
|
|
78
|
+
// accepts — an out-of-range number keeps the class and drops only the
|
|
79
|
+
// number, so the refusal still hands off instead of falling back to
|
|
80
|
+
// `completed`.
|
|
81
|
+
const status = reason === 'upstream-refused'
|
|
82
|
+
&& Number.isInteger(declared.status)
|
|
83
|
+
&& declared.status >= MIN_UPSTREAM_STATUS
|
|
84
|
+
&& declared.status <= MAX_UPSTREAM_STATUS
|
|
85
|
+
? declared.status
|
|
86
|
+
: undefined;
|
|
87
|
+
return {
|
|
88
|
+
outcome: 'refused',
|
|
89
|
+
reason,
|
|
90
|
+
...(status !== undefined ? { status } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (event?.type === 'message.posted'
|
|
94
|
+
&& event.payload?.senderIsHuman === true
|
|
95
|
+
&& turnResult.outcome === 'no_action'
|
|
96
|
+
&& !turnResult.reason) {
|
|
97
|
+
return { outcome: 'declined' };
|
|
98
|
+
}
|
|
99
|
+
return { outcome: 'completed' };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `refused` is a LOCAL release marker: the producer sets it so the claim
|
|
104
|
+
* release can name the outcome (see `claimReleaseFor`), and nothing else reads
|
|
105
|
+
* it. The event ack is a wire payload with its own readers, and the information
|
|
106
|
+
* that belongs there already reaches them as `reason` — so the marker is
|
|
107
|
+
* dropped at the ack boundary rather than widening the ack with an internal
|
|
108
|
+
* flag. Returns the result unchanged when there is no marker, so the common
|
|
109
|
+
* path never sees a rebuilt object.
|
|
110
|
+
*
|
|
111
|
+
* @param {object|undefined} turnResult
|
|
112
|
+
*/
|
|
113
|
+
export const ackResultFor = (turnResult) => {
|
|
114
|
+
if (!turnResult || !turnResult.refused) return turnResult;
|
|
115
|
+
// ts-eslint's `arg`/`vars` ignore pattern is `^_`, and ignoreRestSiblings is
|
|
116
|
+
// off in this repo's config — so the dropped key is renamed rather than
|
|
117
|
+
// left to trip no-unused-vars.
|
|
118
|
+
const { refused: _refused, ...rest } = turnResult;
|
|
119
|
+
return rest;
|
|
120
|
+
};
|
package/src/lib/enforcement.js
CHANGED
|
@@ -434,7 +434,7 @@ export const createClaimKeeper = (client, {
|
|
|
434
434
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
435
435
|
},
|
|
436
436
|
|
|
437
|
-
async release(outcome) {
|
|
437
|
+
async release(outcome, { reason, status } = {}) {
|
|
438
438
|
stopRenewal();
|
|
439
439
|
if (!acquired || lost) return;
|
|
440
440
|
try {
|
|
@@ -442,7 +442,35 @@ export const createClaimKeeper = (client, {
|
|
|
442
442
|
// outcome. Besides keeping old clients' DELETE shape intact, callers
|
|
443
443
|
// that assert their transport arguments must not see a synthetic
|
|
444
444
|
// `undefined` body. An explicit outcome is the new D6.1 contract.
|
|
445
|
-
|
|
445
|
+
//
|
|
446
|
+
// `reason` and `status` ride only with a refusal (TASK-099) and only
|
|
447
|
+
// when the producer named them: the kernel stores the class as the
|
|
448
|
+
// record of why a delivery never happened, so an absent reason must
|
|
449
|
+
// stay absent rather than become the string "undefined".
|
|
450
|
+
if (!outcome) {
|
|
451
|
+
await client.del(path);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const body = {
|
|
455
|
+
outcome,
|
|
456
|
+
...(reason ? { reason } : {}),
|
|
457
|
+
...(status !== undefined ? { status } : {}),
|
|
458
|
+
};
|
|
459
|
+
try {
|
|
460
|
+
await client.del(path, body);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// An older kernel validates the outcome against its own
|
|
463
|
+
// decline/completed enum and answers 400 — as it does for a reason
|
|
464
|
+
// it does not recognise. A CLI that ships before (or runs against a
|
|
465
|
+
// server earlier than) the third outcome must still release its
|
|
466
|
+
// lease, or the message stays claimed until it expires: one retry as
|
|
467
|
+
// `completed`, which is exactly what the old release did. Bounded to
|
|
468
|
+
// a 400 — a 500 or a timeout is not turned into a second write, and
|
|
469
|
+
// a second 400 is not retried again.
|
|
470
|
+
if (outcome !== 'refused' || err?.status !== 400) throw err;
|
|
471
|
+
log(`claim on message ${messageId}: kernel rejected the refused outcome (400) — releasing as completed`);
|
|
472
|
+
await client.del(path, { outcome: 'completed' });
|
|
473
|
+
}
|
|
446
474
|
} catch {
|
|
447
475
|
// Best-effort: a miss just means the lease already expired.
|
|
448
476
|
}
|
package/src/lib/environment.js
CHANGED
|
@@ -69,6 +69,22 @@ export const normalizeSandboxTrust = (sandbox) => (
|
|
|
69
69
|
? { ...sandbox, trust: LEGACY_SANDBOX_TRUST[sandbox.trust] }
|
|
70
70
|
: sandbox
|
|
71
71
|
);
|
|
72
|
+
/**
|
|
73
|
+
* A declared trust resolved THROUGH the legacy table: `internal` → `public`,
|
|
74
|
+
* anything else (including absent) is itself.
|
|
75
|
+
*
|
|
76
|
+
* Exported so every gate reads the mapping once. `normalizeSandboxTrust` above
|
|
77
|
+
* rewrites a whole sandbox object; this is the scalar form for a site that only
|
|
78
|
+
* compares the trust and must not write anything back. The server spells the
|
|
79
|
+
* same predicate `effectiveSandboxTrust` (`grantBrokerConfinement.ts`) — the two
|
|
80
|
+
* sides have to read `internal` the same way or one of them confines a seat the
|
|
81
|
+
* other withholds from (Wren 71662).
|
|
82
|
+
*/
|
|
83
|
+
export const effectiveSandboxTrust = (trust) => (
|
|
84
|
+
typeof trust === 'string' && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, trust)
|
|
85
|
+
? LEGACY_SANDBOX_TRUST[trust]
|
|
86
|
+
: trust
|
|
87
|
+
);
|
|
72
88
|
|
|
73
89
|
const expandHome = (p) => {
|
|
74
90
|
if (!p || typeof p !== 'string') return p;
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
*/
|
|
75
75
|
import { isGrantBrokerUrl } from './adapters/pi-mcp-client.mjs';
|
|
76
76
|
import { ADAPTERS_WITH_DEFAULT_SANDBOX } from './default-environment.js';
|
|
77
|
-
import {
|
|
77
|
+
import { effectiveSandboxTrust } from './environment.js';
|
|
78
78
|
import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from './sandbox/mode.js';
|
|
79
79
|
|
|
80
80
|
/** One typed code, two emitters (`decidedBy` says which one spoke). */
|
|
@@ -92,13 +92,6 @@ export const ENFORCING_MODES = new Set([...PUBLIC_SANDBOX_MODES, 'bwrap']);
|
|
|
92
92
|
const URL_PLACEHOLDERS = ['${COMMONLY_API_URL}', '${COMMONLY_INSTANCE_URL}'];
|
|
93
93
|
const PARSE_ANCHOR = 'https://grant-declaration.invalid';
|
|
94
94
|
|
|
95
|
-
/** `internal` → `public`; anything else (including absent) is itself. */
|
|
96
|
-
const effectiveTrust = (trust) => (
|
|
97
|
-
typeof trust === 'string' && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, trust)
|
|
98
|
-
? LEGACY_SANDBOX_TRUST[trust]
|
|
99
|
-
: trust
|
|
100
|
-
);
|
|
101
|
-
|
|
102
95
|
/**
|
|
103
96
|
* A bound instance is only trimmed here (`agent.js` does the same). Any
|
|
104
97
|
* trailing slash it carries is deliberately left alone: the doubled slash it
|
|
@@ -190,7 +183,7 @@ export const confinementReason = (environment, adapter, platform = process.platf
|
|
|
190
183
|
if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return 'sandbox_absent';
|
|
191
184
|
const declared = sandbox;
|
|
192
185
|
if (declared.mode === 'none') return 'sandbox_mode_none';
|
|
193
|
-
if (
|
|
186
|
+
if (effectiveSandboxTrust(declared.trust) !== 'public') return 'sandbox_trust_not_public';
|
|
194
187
|
const mode = resolvePublicSandboxMode(declared, platform);
|
|
195
188
|
if (typeof mode !== 'string' || !ENFORCING_MODES.has(mode)) return 'sandbox_mode_unenforceable';
|
|
196
189
|
return null;
|
|
@@ -220,7 +213,7 @@ const detailFor = (reason, adapter, environment) => {
|
|
|
220
213
|
}
|
|
221
214
|
const trust = environment?.sandbox?.trust;
|
|
222
215
|
const shown = trust === undefined ? 'absent' : `'${String(trust)}'`;
|
|
223
|
-
return `the declared sandbox.trust is ${shown}${shown === 'absent' ? '' : ` (effective '${String(
|
|
216
|
+
return `the declared sandbox.trust is ${shown}${shown === 'absent' ? '' : ` (effective '${String(effectiveSandboxTrust(trust))}')`},`
|
|
224
217
|
+ " and no host confines a seat whose trust is not 'public'; declare sandbox.trust 'public'" + ` ${drop}`;
|
|
225
218
|
};
|
|
226
219
|
|
package/src/lib/sandbox/mode.js
CHANGED
|
@@ -12,23 +12,30 @@
|
|
|
12
12
|
* darwin → 'workspace' (the claude adapter maps it to Seatbelt)
|
|
13
13
|
* else → 'bwrap' (bubblewrap; attach checks it is installed)
|
|
14
14
|
*
|
|
15
|
-
* Resolution is PUBLIC-only on purpose
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* Resolution is PUBLIC-only on purpose, and PUBLIC IS THE EFFECTIVE TRUST: a
|
|
16
|
+
* legacy `internal` declaration reads as `public` (environment.js, Wren 69585),
|
|
17
|
+
* so the mapping is applied before this comparison. Comparing the raw value
|
|
18
|
+
* made `{ trust: 'internal' }` resolve to no mode at all — the mapping was
|
|
19
|
+
* applied in `confinementReason` and not here, which is half a rule, and the
|
|
20
|
+
* half that failed was the one that WITHHOLDS: the grant broker was taken from
|
|
21
|
+
* a seat this daemon would have confined. A non-public declaration keeps
|
|
22
|
+
* whatever mode it wrote, and an adapter that cannot enforce it is expected to
|
|
23
|
+
* refuse rather than quietly spawn unconfined. An explicit mode on a public
|
|
24
|
+
* declaration still wins — including a wrong one, which the caller then refuses
|
|
25
|
+
* (Vera 69548: an unresolvable public mode must not fall through to a bare,
|
|
26
|
+
* unconfined spawn).
|
|
21
27
|
*
|
|
22
28
|
* NOT used by the codex adapter. Codex's `mode` is read-vs-write access, not a
|
|
23
29
|
* host mechanism — its permission profiles run on both platforms — so its
|
|
24
30
|
* derived default is 'workspace' everywhere.
|
|
25
31
|
*/
|
|
32
|
+
import { effectiveSandboxTrust } from '../environment.js';
|
|
26
33
|
|
|
27
34
|
export const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
|
|
28
35
|
|
|
29
36
|
export const resolvePublicSandboxMode = (sandbox, platform = process.platform) => {
|
|
30
37
|
const declared = sandbox?.mode;
|
|
31
|
-
if (sandbox?.trust !== 'public') return declared;
|
|
38
|
+
if (effectiveSandboxTrust(sandbox?.trust) !== 'public') return declared;
|
|
32
39
|
if (declared !== undefined && declared !== null) return declared;
|
|
33
40
|
return platform === 'darwin' ? 'workspace' : 'bwrap';
|
|
34
41
|
};
|