@ravefox/space 0.2.1 → 0.3.0

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 (163) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +80 -31
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +27 -46
  15. package/NOTICE +0 -55
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. package/src/lib/workspace-trust.js +0 -162
@@ -1,123 +0,0 @@
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?, inject? }],
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
- * `inject: true` keeps the value out of the run: its env gets a per-run proxy
15
- * token under the name instead, and a loopback provider proxy adds the real
16
- * key to outbound requests. Default false: the run holds the value.
17
- *
18
- * A tool pattern is `<kind>` or `<kind>:<title glob>`, where kind is one of
19
- * ACP's ToolKind values or `*`. `execute` matches every execute call;
20
- * `execute:git status*` matches execute calls whose title starts that way;
21
- * `*:*.env*` matches any kind by title. Globs know `*` and `?` only.
22
- *
23
- * The title is text the agent chose. A title-glob deny narrows what the agent
24
- * can do; a title-glob allow is only as strong as the agent's honesty about
25
- * its title, so prefer kind-level allows and put execute behind ask.
26
- *
27
- * Precedence is deny > ask > allow, and a call no rule names is denied.
28
- *
29
- * `network` rules are refused for now rather than accepted and ignored: a run
30
- * would believe it was fenced when nothing enforces the fence. TODO: egress
31
- * allowlist once a sandbox can enforce it.
32
- */
33
-
34
- export const TOOL_KINDS = Object.freeze(['read', 'edit', 'delete', 'move', 'search', 'execute', 'think', 'fetch', 'other']);
35
- export const CREDENTIAL_SOURCES = Object.freeze(['env', 'file', 'resolver']);
36
- const RULE_LISTS = ['deny', 'ask', 'allow']; // checked in this order: the precedence
37
- const VALIDATED = Symbol('validated grant');
38
-
39
- const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
40
- // Names the run env is built from. A credential under one of these names would
41
- // overwrite the boundary itself (a granted HOME points back at the real login).
42
- 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_]+)$/;
43
-
44
- const fail = (message) => { throw new Error(`grant: ${message}`); };
45
- const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
46
- const refuseUnknownKeys = (value, allowed, where) => {
47
- for (const key of Object.keys(value)) {
48
- if (!allowed.includes(key)) fail(`unknown field ${where}${key}`);
49
- }
50
- };
51
-
52
- const globToRegExp = (glob) => new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[\\s\\S]*').replace(/\?/g, '[\\s\\S]')}$`);
53
-
54
- /** Parse once at validation so matching never re-reads a malformed pattern. */
55
- const parsePattern = (pattern, where) => {
56
- if (typeof pattern !== 'string' || !pattern.trim()) fail(`${where} must be a non-empty string`);
57
- const colon = pattern.indexOf(':');
58
- const kind = colon === -1 ? pattern : pattern.slice(0, colon);
59
- const title = colon === -1 ? null : pattern.slice(colon + 1);
60
- if (kind !== '*' && !TOOL_KINDS.includes(kind)) fail(`${where} names unknown tool kind "${kind}"`);
61
- if (title !== null && !title) fail(`${where} has an empty title glob`);
62
- return Object.freeze({ pattern, kind, title: title === null ? null : globToRegExp(title) });
63
- };
64
-
65
- const validateCredential = (credential, index, seen) => {
66
- const where = `credentials[${index}]`;
67
- if (!isPlainObject(credential)) fail(`${where} must be an object`);
68
- refuseUnknownKeys(credential, ['name', 'from', 'ref', 'inject'], `${where}.`);
69
- const { name, from, ref, inject } = credential;
70
- if (typeof name !== 'string' || !ENV_NAME.test(name)) fail(`${where}.name must be an ENV_NAME (A-Z, 0-9, _)`);
71
- if (RESERVED_NAME.test(name)) fail(`${where}.name ${name} is reserved for the run env`);
72
- if (seen.has(name)) fail(`${where}.name ${name} is granted twice`);
73
- seen.add(name);
74
- if (!CREDENTIAL_SOURCES.includes(from)) fail(`${where}.from must be one of ${CREDENTIAL_SOURCES.join(', ')}`);
75
- if (ref !== undefined && (typeof ref !== 'string' || !ref)) fail(`${where}.ref must be a non-empty string`);
76
- if (from === 'env' && ref !== undefined && !ENV_NAME.test(ref)) fail(`${where}.ref must be an ENV_NAME for from: env`);
77
- if (from === 'file' && (ref === undefined || !ref.startsWith('/'))) fail(`${where}.ref must be an absolute path for from: file`);
78
- if (inject !== undefined && typeof inject !== 'boolean') fail(`${where}.inject must be true or false`);
79
- return Object.freeze({ name, from, ...(ref === undefined ? {} : { ref }), ...(inject ? { inject } : {}) });
80
- };
81
-
82
- /** Validate and freeze a grant. Throws on the first problem; never repairs one. */
83
- export const validateGrant = (grant) => {
84
- if (grant && grant[VALIDATED]) return grant;
85
- if (!isPlainObject(grant)) fail('must be an object');
86
- if ('network' in grant) fail('network rules are not enforced yet; remove the field rather than rely on it');
87
- refuseUnknownKeys(grant, ['credentials', 'tools'], '');
88
- const credentials = grant.credentials ?? [];
89
- if (!Array.isArray(credentials)) fail('credentials must be an array');
90
- const seen = new Set();
91
- const tools = grant.tools ?? {};
92
- if (!isPlainObject(tools)) fail('tools must be an object');
93
- refuseUnknownKeys(tools, RULE_LISTS, 'tools.');
94
- const rules = {};
95
- for (const list of RULE_LISTS) {
96
- const patterns = tools[list] ?? [];
97
- if (!Array.isArray(patterns)) fail(`tools.${list} must be an array`);
98
- rules[list] = Object.freeze(patterns.map((pattern, i) => parsePattern(pattern, `tools.${list}[${i}]`)));
99
- }
100
- return Object.freeze({
101
- [VALIDATED]: true,
102
- credentials: Object.freeze(credentials.map((c, i) => validateCredential(c, i, seen))),
103
- tools: Object.freeze(rules),
104
- });
105
- };
106
- const matches = (parsed, kind, title) => (parsed.kind === '*' || parsed.kind === kind)
107
- && (parsed.title === null || parsed.title.test(title));
108
-
109
- /**
110
- * Which rule decides this tool call. ACP makes `kind` optional and means
111
- * `other` when it is absent, so that is what an unknown or missing kind is.
112
- * Returns { decision: 'allow'|'ask'|'deny', rule: { list, pattern } }.
113
- */
114
- export const matchToolCall = (grant, toolCall = {}) => {
115
- const valid = validateGrant(grant);
116
- const kind = TOOL_KINDS.includes(toolCall?.kind) ? toolCall.kind : 'other';
117
- const title = typeof toolCall?.title === 'string' ? toolCall.title : '';
118
- for (const list of RULE_LISTS) {
119
- const hit = valid.tools[list].find((parsed) => matches(parsed, kind, title));
120
- if (hit) return { decision: list, rule: { list, pattern: hit.pattern } };
121
- }
122
- return { decision: 'deny', rule: { list: 'default', pattern: null } };
123
- };
@@ -1,213 +0,0 @@
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
- };