@ravefox/space 0.2.1 → 0.4.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.
- package/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- 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
|
-
};
|