dsh-device-fleet 0.0.0-stage → 0.1.0-dev.2

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 (51) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -2
  4. package/cordis.patch.yml +26 -0
  5. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  6. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  7. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  8. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  9. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  10. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  11. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  12. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  13. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  14. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  15. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  16. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  17. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  18. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  19. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  20. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  21. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  22. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  23. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  24. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  25. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  26. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  27. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  28. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  29. package/docs/notes/design-decisions-summary.md +49 -0
  30. package/docs/notes/dev-release.md +28 -0
  31. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  32. package/docs/notes/network-v1-v2-implementation.md +71 -0
  33. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  34. package/package.json +42 -3
  35. package/src/host/direct-call.js +190 -0
  36. package/src/host/execution-slot.js +82 -0
  37. package/src/host/index.js +6 -0
  38. package/src/host/store.js +128 -0
  39. package/src/host/tool-policy.js +150 -0
  40. package/src/host/tools.js +215 -0
  41. package/src/index.js +3 -0
  42. package/src/net/direct.js +69 -0
  43. package/src/net/discovery.js +105 -0
  44. package/src/net/fleet-node.js +414 -0
  45. package/src/net/identity.js +156 -0
  46. package/src/net/index.js +9 -0
  47. package/src/net/protocol.js +73 -0
  48. package/src/net/relay-client.js +258 -0
  49. package/src/net/relay-server.js +366 -0
  50. package/src/net/secure-channel.js +538 -0
  51. package/src/plugin/index.js +285 -0
@@ -0,0 +1,190 @@
1
+ // Direct-call host/client over the fleet RPC layer (ADR-0003, ADR-0004, ADR-0014).
2
+ //
3
+ // Host side runs on the resource-owner device: it applies Tool Policy, may ask
4
+ // for local approval, then executes through the Execution Slot (or a direct fs
5
+ // read for list/fetch). Client side runs on the caller device and backs the
6
+ // `fleet_*` model tools.
7
+ import { DEFAULT_SHELL_TOOL } from './execution-slot.js'
8
+
9
+ const DEFAULT_POLICY_TIMEOUT_MS = 60_000
10
+
11
+ function toolText(result) {
12
+ if (!result) return ''
13
+ if (result.isError) return `Error: ${result.text || 'tool failed'}`
14
+ return result.text ?? ''
15
+ }
16
+
17
+ export class DirectCallHost {
18
+ /**
19
+ * @param {object} options
20
+ * @param {object} options.slot ExecutionSlot
21
+ * @param {import('./tool-policy.js').ToolPolicy} options.policy
22
+ * @param {{resolve: Function, listDir: Function, stat?: Function, readText?: Function}} [options.fs]
23
+ * @param {(request: object) => Promise<boolean>} [options.requestApproval]
24
+ * @param {string} options.deviceId
25
+ * @param {string} [options.name]
26
+ * @param {string} [options.pluginVersion]
27
+ * @param {string} [options.dshVersion]
28
+ * @param {string[]} [options.capabilities]
29
+ * @param {string} [options.shellTool]
30
+ */
31
+ constructor({
32
+ slot,
33
+ policy,
34
+ fs,
35
+ requestApproval,
36
+ deviceId,
37
+ name = 'device',
38
+ pluginVersion = '0.0.0',
39
+ dshVersion = 'unknown',
40
+ capabilities = [],
41
+ shellTool = DEFAULT_SHELL_TOOL,
42
+ approvalTimeoutMs = DEFAULT_POLICY_TIMEOUT_MS,
43
+ }) {
44
+ this.slot = slot
45
+ this.policy = policy
46
+ this.fs = fs
47
+ this.requestApproval = requestApproval
48
+ this.deviceId = deviceId
49
+ this.name = name
50
+ this.pluginVersion = pluginVersion
51
+ this.dshVersion = dshVersion
52
+ this.capabilities = capabilities
53
+ this.shellTool = shellTool
54
+ this.approvalTimeoutMs = approvalTimeoutMs
55
+ }
56
+
57
+ info() {
58
+ return {
59
+ deviceId: this.deviceId,
60
+ name: this.name,
61
+ pluginVersion: this.pluginVersion,
62
+ dshVersion: this.dshVersion,
63
+ capabilities: this.capabilities,
64
+ policy: this.policy.summarize(),
65
+ }
66
+ }
67
+
68
+ async handleRequest({ deviceId, method, params }) {
69
+ switch (method) {
70
+ case 'fleet.ping':
71
+ return { pong: true, deviceId: this.deviceId }
72
+ case 'fleet.info':
73
+ return this.info()
74
+ case 'fleet.tools':
75
+ return { deviceId: this.deviceId, tools: await this.slot.listTools(), policy: this.policy.summarize() }
76
+ case 'fleet.call':
77
+ return this.handleCall({ fromDeviceId: deviceId, request: params ?? {} })
78
+ default:
79
+ throw Object.assign(new Error(`unknown fleet method: ${String(method)}`), { code: 'unknown-method' })
80
+ }
81
+ }
82
+
83
+ async handleCall({ fromDeviceId, request }) {
84
+ const capability = request.capability ?? 'tool'
85
+ const tool = request.tool ?? null
86
+ const args = request.args ?? {}
87
+ const decision = this.policy.decide({ capability, tool, args })
88
+ if (decision.decision === 'deny') {
89
+ throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability, tool } })
90
+ }
91
+ if (decision.decision === 'ask') {
92
+ const approved = await this.askApproval({ fromDeviceId, capability, tool, args, reason: decision.reason })
93
+ if (!approved) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
94
+ this.policy.grant(decision.key, { scope: 'once' })
95
+ }
96
+ const startedAt = Date.now()
97
+ const result = await this.execute({ capability, tool, args })
98
+ return {
99
+ ...result,
100
+ meta: {
101
+ ...(result.meta ?? {}),
102
+ capability,
103
+ tool,
104
+ deviceId: this.deviceId,
105
+ durationMs: Date.now() - startedAt,
106
+ },
107
+ }
108
+ }
109
+
110
+ async askApproval({ fromDeviceId, capability, tool, args, reason }) {
111
+ if (typeof this.requestApproval !== 'function') return false
112
+ try {
113
+ const approved = await this.requestApproval({ fromDeviceId, capability, tool, args, reason })
114
+ return approved === true
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ async execute({ capability, tool, args }) {
121
+ switch (capability) {
122
+ case 'read':
123
+ case 'grep':
124
+ case 'run':
125
+ case 'tool': {
126
+ const name = capability === 'run' ? this.shellTool : tool
127
+ if (typeof name !== 'string' || name === '') {
128
+ throw Object.assign(new Error(`capability ${capability} needs a tool name`), { code: 'bad-request' })
129
+ }
130
+ return this.slot.executeTool({ name, args })
131
+ }
132
+ case 'list':
133
+ return this.readDirectory(args)
134
+ case 'fetch':
135
+ return this.fetchFile(args)
136
+ default:
137
+ throw Object.assign(new Error(`unsupported capability: ${String(capability)}`), { code: 'unsupported' })
138
+ }
139
+ }
140
+
141
+ async readDirectory(args) {
142
+ if (!this.fs) throw Object.assign(new Error('filesystem is unavailable'), { code: 'unsupported' })
143
+ const path = String(args?.path ?? args?.dir ?? '')
144
+ if (!path) throw Object.assign(new Error('list needs path'), { code: 'bad-request' })
145
+ const target = await this.fs.resolve(path)
146
+ const entries = await this.fs.listDir(target)
147
+ const lines = entries.map((entry) => `${entry.type}\t${entry.name}${entry.size === undefined ? '' : `\t${entry.size}`}`)
148
+ return { isError: false, text: lines.join('\n') || '(empty directory)', content: [], meta: { path } }
149
+ }
150
+
151
+ async fetchFile(args) {
152
+ if (!this.fs) throw Object.assign(new Error('filesystem is unavailable'), { code: 'unsupported' })
153
+ const path = String(args?.path ?? '')
154
+ if (!path) throw Object.assign(new Error('fetch needs path'), { code: 'bad-request' })
155
+ const target = await this.fs.resolve(path)
156
+ if (typeof this.fs.readText !== 'function') {
157
+ throw Object.assign(new Error('fetch is unavailable on this device'), { code: 'unsupported' })
158
+ }
159
+ const text = await this.fs.readText(target)
160
+ return { isError: false, text, content: [], meta: { path, bytes: Buffer.byteLength(text) } }
161
+ }
162
+ }
163
+
164
+ export class DirectCallClient {
165
+ constructor({ node }) {
166
+ this.node = node
167
+ }
168
+
169
+ async info(deviceId) {
170
+ return this.node.request(deviceId, 'fleet.info', {})
171
+ }
172
+
173
+ async listTools(deviceId) {
174
+ return this.node.request(deviceId, 'fleet.tools', {})
175
+ }
176
+
177
+ async call(deviceId, { capability, tool = null, args = {} }, options = {}) {
178
+ return this.node.request(deviceId, 'fleet.call', { capability, tool, args }, options)
179
+ }
180
+
181
+ /** Format a remote result for a model-facing text tool. */
182
+ render(result) {
183
+ if (result?.isError) return `Error: ${result.text || 'remote tool failed'}`
184
+ const meta = result?.meta ?? {}
185
+ const footer = meta.durationMs !== undefined ? `\n[${String(meta.capability ?? 'call')} on ${String(meta.deviceId ?? 'peer')} · ${String(meta.durationMs)}ms]` : ''
186
+ return `${result?.text ?? ''}${footer}`.trim()
187
+ }
188
+ }
189
+
190
+ export { toolText }
@@ -0,0 +1,82 @@
1
+ // Execution Slot: a hidden DSH session/agent that gives remote direct calls a
2
+ // legal tool-execution context (cwd, sandbox policy, approval routing) without
3
+ // running any model turn. See ADR-0003 and ADR-0019.
4
+ import { randomUUID } from 'node:crypto'
5
+
6
+ export const DEFAULT_SHELL_TOOL = process.platform === 'win32' ? 'pwsh' : 'bash'
7
+
8
+ export class ExecutionSlot {
9
+ /**
10
+ * @param {{sessionController: object, tools: object, config?: {cwd?: string, agentPreset?: string}}} options
11
+ */
12
+ constructor({ sessionController, tools, config = {} }) {
13
+ this.sessionController = sessionController
14
+ this.tools = tools
15
+ this.config = config
16
+ this.agent = null
17
+ this.sessionId = null
18
+ this.initializing = null
19
+ }
20
+
21
+ async ensureAgent() {
22
+ if (this.agent) return this.agent
23
+ if (this.initializing) return this.initializing
24
+ this.initializing = (async () => {
25
+ const created = await this.sessionController.create({
26
+ cwd: this.config.cwd,
27
+ agentPreset: this.config.agentPreset,
28
+ })
29
+ const sessionId = created?.sessionId
30
+ if (!sessionId) throw Object.assign(new Error('execution slot could not create a session'), { code: 'slot-unavailable' })
31
+ const resolved = await this.sessionController.resolveAgent(sessionId)
32
+ if (!resolved?.agent) {
33
+ const reason = resolved?.error?.code ?? 'session/agent-busy'
34
+ throw Object.assign(new Error(`execution slot agent unavailable: ${String(reason)}`), { code: 'slot-unavailable' })
35
+ }
36
+ this.sessionId = sessionId
37
+ this.agent = resolved.agent
38
+ return this.agent
39
+ })().finally(() => {
40
+ this.initializing = null
41
+ })
42
+ return this.initializing
43
+ }
44
+
45
+ /** Model-facing tool schemas visible to the execution slot agent. */
46
+ async listTools() {
47
+ const agent = await this.ensureAgent()
48
+ const schemas = this.tools.schemas?.(agent) ?? this.tools.schemas?.() ?? []
49
+ return schemas
50
+ }
51
+
52
+ /**
53
+ * Execute one tool inside the slot context and normalize the result.
54
+ * @param {{name: string, args: object, signal?: AbortSignal}} call
55
+ */
56
+ async executeTool({ name, args = {}, signal }) {
57
+ const agent = await this.ensureAgent()
58
+ const batch = await this.tools.execute({
59
+ calls: [{ id: randomUUID(), name, arguments: JSON.stringify(args ?? {}) }],
60
+ agent,
61
+ signal,
62
+ })
63
+ const first = batch?.results?.[0]?.result
64
+ if (!first) throw Object.assign(new Error(`tool ${name} returned no result`), { code: 'tool-no-result' })
65
+ const text = (first.content ?? [])
66
+ .map((block) => (block?.type === 'text' ? block.text : `[${String(block?.type ?? 'block')}]`))
67
+ .join('\n')
68
+ return {
69
+ isError: first.isError === true,
70
+ text,
71
+ content: first.content ?? [],
72
+ error: first.error ?? null,
73
+ meta: first.meta ?? null,
74
+ }
75
+ }
76
+
77
+ /** Tool names currently visible to the slot agent. */
78
+ async toolNames() {
79
+ const tools = await this.listTools()
80
+ return tools.map((tool) => tool.name).filter((name) => typeof name === 'string')
81
+ }
82
+ }
@@ -0,0 +1,6 @@
1
+ // Public host-side exports for the fleet plugin.
2
+ export * from './store.js'
3
+ export * from './tool-policy.js'
4
+ export * from './execution-slot.js'
5
+ export * from './direct-call.js'
6
+ export * from './tools.js'
@@ -0,0 +1,128 @@
1
+ // Persistent device state for the fleet plugin: identity, fleet, roster, config.
2
+ // Everything lives under `$DSH_HOME/device-fleet/` (or DEVICE_FLEET_DATA_DIR).
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { createFleet, decodeInvite, encodeInvite, generateIdentity } from '../net/identity.js'
6
+
7
+ export function resolveDataDir({ dshHome, override } = {}) {
8
+ if (override) return path.resolve(override)
9
+ if (process.env.DEVICE_FLEET_DATA_DIR) return path.resolve(process.env.DEVICE_FLEET_DATA_DIR)
10
+ const home = dshHome ?? process.env.DSH_HOME ?? process.cwd()
11
+ return path.join(home, 'device-fleet')
12
+ }
13
+
14
+ function readJson(file, fallback) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
17
+ } catch {
18
+ return fallback
19
+ }
20
+ }
21
+
22
+ function writeJsonAtomic(file, value) {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true })
24
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
25
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2))
26
+ fs.renameSync(tmp, file)
27
+ }
28
+
29
+ /**
30
+ * Load or create the persistent device state.
31
+ * @returns {{dataDir: string, identity: object, fleet: object, roster: object[], config: object, enrollment: object|null, createdFleet: boolean}}
32
+ */
33
+ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
34
+ const dir = resolveDataDir({ dshHome, override: dataDir })
35
+ fs.mkdirSync(dir, { recursive: true })
36
+
37
+ let identity = readJson(path.join(dir, 'identity.json'), null)
38
+ if (!identity) {
39
+ identity = generateIdentity({ name })
40
+ writeJsonAtomic(path.join(dir, 'identity.json'), identity)
41
+ }
42
+
43
+ let createdFleet = false
44
+ let fleet = readJson(path.join(dir, 'fleet.json'), null)
45
+ if (!fleet) {
46
+ fleet = createFleet({ name: `${name} fleet` })
47
+ writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
48
+ createdFleet = true
49
+ }
50
+
51
+ const roster = readJson(path.join(dir, 'roster.json'), [])
52
+ const config = readJson(path.join(dir, 'config.json'), {})
53
+ let enrollment = readJson(path.join(dir, 'enrollment.json'), null)
54
+ let pendingEnrollment = readJson(path.join(dir, 'enrollment.pending.json'), null)
55
+
56
+ // A pending invite written by `fleet_join` takes effect on the next load:
57
+ // enrolling while the network node is already running would leave the two
58
+ // sides on different fleets.
59
+ if (pendingEnrollment?.code) {
60
+ try {
61
+ const decoded = decodeInvite(pendingEnrollment.code)
62
+ fleet = decoded.fleet
63
+ enrollment = {
64
+ joinedAt: Date.now(),
65
+ accountFingerprint: decoded.accountFingerprint ?? null,
66
+ relay: decoded.relay ?? null,
67
+ }
68
+ writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
69
+ writeJsonAtomic(path.join(dir, 'enrollment.json'), enrollment)
70
+ fs.unlinkSync(path.join(dir, 'enrollment.pending.json'))
71
+ pendingEnrollment = null
72
+ } catch {
73
+ // Keep the pending file; the user can fix the invite and restart again.
74
+ }
75
+ }
76
+
77
+ return { dataDir: dir, identity, fleet, roster: Array.isArray(roster) ? roster : [], config, enrollment, pendingEnrollment, createdFleet }
78
+ }
79
+
80
+ /** Queue an invite for the next plugin load (the running node keeps its fleet). */
81
+ export function writePendingEnrollment(state, code) {
82
+ decodeInvite(code) // validate before writing
83
+ writeJsonAtomic(path.join(state.dataDir, 'enrollment.pending.json'), { code, queuedAt: Date.now() })
84
+ }
85
+
86
+ export function saveIdentity(state) {
87
+ writeJsonAtomic(path.join(state.dataDir, 'identity.json'), state.identity)
88
+ }
89
+
90
+ export function saveFleet(state) {
91
+ writeJsonAtomic(path.join(state.dataDir, 'fleet.json'), state.fleet)
92
+ }
93
+
94
+ export function saveRoster(state, roster) {
95
+ state.roster = roster
96
+ writeJsonAtomic(path.join(state.dataDir, 'roster.json'), roster)
97
+ }
98
+
99
+ export function saveConfig(state, config) {
100
+ state.config = config
101
+ writeJsonAtomic(path.join(state.dataDir, 'config.json'), config)
102
+ }
103
+
104
+ /** Create an invite for this device's fleet. */
105
+ export function createInvite(state, { accountFingerprint = null, relay = null } = {}) {
106
+ return encodeInvite({ fleet: state.fleet, accountFingerprint, relay })
107
+ }
108
+
109
+ /** Join a fleet from an invite code (overwrites fleet/enrollment, keeps identity). */
110
+ export function enrollFromInvite(state, code) {
111
+ const decoded = decodeInvite(code)
112
+ state.fleet = decoded.fleet
113
+ state.enrollment = {
114
+ joinedAt: Date.now(),
115
+ accountFingerprint: decoded.accountFingerprint ?? null,
116
+ relay: decoded.relay ?? null,
117
+ }
118
+ saveFleet(state)
119
+ writeJsonAtomic(path.join(state.dataDir, 'enrollment.json'), state.enrollment)
120
+ return state.enrollment
121
+ }
122
+
123
+ /** Check a locally observed account fingerprint against the invite's binding. */
124
+ export function accountMatches(state, fingerprint) {
125
+ const expected = state.enrollment?.accountFingerprint ?? null
126
+ if (!expected) return true // unbound invite: accept the first account and caller may bind
127
+ return fingerprint !== null && fingerprint === expected
128
+ }
@@ -0,0 +1,150 @@
1
+ // Target-device tool policy (ADR-0004, ADR-0019, ADR-0020).
2
+ // The policy owns: allowed roots, per-capability mode (auto/ask/deny),
3
+ // the raw-tool allowlist, and short-lived grants created by approvals.
4
+ import path from 'node:path'
5
+
6
+ export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool']
7
+
8
+ export const DEFAULT_POLICY = Object.freeze({
9
+ allowedRoots: [],
10
+ capabilities: {
11
+ read: 'auto',
12
+ list: 'auto',
13
+ grep: 'auto',
14
+ fetch: 'auto',
15
+ run: 'ask',
16
+ tool: 'deny',
17
+ },
18
+ allowedTools: [],
19
+ approvalTtlMs: 15 * 60 * 1000,
20
+ })
21
+
22
+ /** True when `candidate` resolves inside one of `roots`. */
23
+ export function pathWithinRoots(roots, candidate) {
24
+ const resolved = path.resolve(String(candidate))
25
+ return roots.some((root) => {
26
+ const base = path.resolve(String(root))
27
+ const relative = path.relative(base, resolved)
28
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
29
+ })
30
+ }
31
+
32
+ export class ToolPolicy {
33
+ constructor(config = {}) {
34
+ this.config = normalizeConfig(config)
35
+ /** @type {Map<string, {expiresAt: number, remaining: number}>} */
36
+ this.grants = new Map()
37
+ }
38
+
39
+ get roots() {
40
+ return this.config.allowedRoots
41
+ }
42
+
43
+ summarize() {
44
+ return {
45
+ allowedRoots: this.roots,
46
+ capabilities: { ...this.config.capabilities },
47
+ allowedTools: [...this.config.allowedTools],
48
+ }
49
+ }
50
+
51
+ /** Decide one call: { decision: 'allow' | 'ask' | 'deny', reason, key } */
52
+ decide({ capability, tool = null, args = {} } = {}) {
53
+ const capabilityName = CAPABILITIES.includes(capability) ? capability : 'tool'
54
+ const mode = this.config.capabilities[capabilityName] ?? 'deny'
55
+ const grantKey = `${capabilityName}:${capabilityName === 'tool' ? String(tool) : '*'}`
56
+ if (this.consumeGrant(grantKey)) return { decision: 'allow', reason: 'approved grant', key: grantKey, capability: capabilityName }
57
+
58
+ const paths = collectPaths(args)
59
+ const needsRoots = ['read', 'list', 'grep', 'fetch'].includes(capabilityName)
60
+ if (needsRoots) {
61
+ if (this.roots.length === 0) {
62
+ return { decision: 'deny', reason: 'no allowed roots configured on this device', key: grantKey, capability: capabilityName }
63
+ }
64
+ for (const candidate of paths) {
65
+ if (!pathWithinRoots(this.roots, candidate)) {
66
+ return {
67
+ decision: 'deny',
68
+ reason: `path is outside the allowed roots: ${String(candidate)}`,
69
+ key: grantKey,
70
+ capability: capabilityName,
71
+ }
72
+ }
73
+ }
74
+ }
75
+ if (capabilityName === 'run' && this.config.capabilities.run === 'allow') {
76
+ return { decision: 'allow', reason: 'run is allowed by policy', key: grantKey, capability: capabilityName }
77
+ }
78
+ if (capabilityName === 'tool') {
79
+ if (!this.config.allowedTools.includes(String(tool))) {
80
+ return {
81
+ decision: 'deny',
82
+ reason: `tool ${String(tool)} is not in the allowed tools list`,
83
+ key: grantKey,
84
+ capability: capabilityName,
85
+ }
86
+ }
87
+ return { decision: 'allow', reason: 'tool is allowlisted', key: grantKey, capability: capabilityName }
88
+ }
89
+ if (mode === 'allow') return { decision: 'allow', reason: 'capability is allowed by policy', key: grantKey, capability: capabilityName }
90
+ if (mode === 'auto') return { decision: 'allow', reason: 'capability is auto-approved', key: grantKey, capability: capabilityName }
91
+ if (mode === 'ask') return { decision: 'ask', reason: 'capability requires local approval', key: grantKey, capability: capabilityName }
92
+ return { decision: 'deny', reason: 'capability is disabled by policy', key: grantKey, capability: capabilityName }
93
+ }
94
+
95
+ /** Remember an approval as a expiring grant (TTL scope) or allow one call (once scope). */
96
+ grant(key, { scope = 'once', ttlMs = this.config.approvalTtlMs, now = Date.now() } = {}) {
97
+ if (typeof key !== 'string' || key === '') return
98
+ const current = this.grants.get(key)
99
+ if (scope === 'once') {
100
+ this.grants.set(key, { expiresAt: now + ttlMs, remaining: (current?.remaining ?? 0) + 1 })
101
+ return
102
+ }
103
+ this.grants.set(key, { expiresAt: now + ttlMs, remaining: Infinity })
104
+ }
105
+
106
+ consumeGrant(key) {
107
+ const grant = this.grants.get(key)
108
+ if (!grant) return false
109
+ if (grant.expiresAt <= Date.now()) {
110
+ this.grants.delete(key)
111
+ return false
112
+ }
113
+ if (Number.isFinite(grant.remaining)) {
114
+ grant.remaining -= 1
115
+ if (grant.remaining <= 0) this.grants.delete(key)
116
+ }
117
+ return true
118
+ }
119
+
120
+ revokeAll() {
121
+ this.grants.clear()
122
+ }
123
+ }
124
+
125
+ function normalizeConfig(config) {
126
+ const capabilities = { ...DEFAULT_POLICY.capabilities }
127
+ for (const [key, value] of Object.entries(config.capabilities ?? {})) {
128
+ if (CAPABILITIES.includes(key) && ['auto', 'ask', 'deny', 'allow'].includes(value)) capabilities[key] = value
129
+ }
130
+ return {
131
+ allowedRoots: Array.isArray(config.allowedRoots) ? config.allowedRoots.map(String) : [],
132
+ capabilities,
133
+ allowedTools: Array.isArray(config.allowedTools) ? config.allowedTools.map(String) : [],
134
+ approvalTtlMs:
135
+ Number.isFinite(config.approvalTtlMs) && config.approvalTtlMs > 0
136
+ ? config.approvalTtlMs
137
+ : DEFAULT_POLICY.approvalTtlMs,
138
+ }
139
+ }
140
+
141
+ function collectPaths(args) {
142
+ const paths = []
143
+ for (const key of ['path', 'file_path', 'cwd', 'dir', 'directory']) {
144
+ if (typeof args?.[key] === 'string' && args[key] !== '') paths.push(args[key])
145
+ }
146
+ if (Array.isArray(args?.paths)) {
147
+ for (const value of args.paths) if (typeof value === 'string' && value !== '') paths.push(value)
148
+ }
149
+ return paths
150
+ }