From 40c0297826b670eacf68d1c44d82f6f663510dd4 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 5 Sep 2026 17:23:18 +0800 Subject: [PATCH 1/7] fix(runtime): enforce persistent permission denies (#3351) Generated-by: Codex --- packages/cli/README.md | 18 + packages/cli/README.zh-CN.md | 18 + packages/cli/src/__tests__/cli.test.ts | 1 + .../src/__tests__/permissions-command.test.ts | 130 ++++++ packages/cli/src/cli-core.ts | 18 + .../cli/src/permissions-command-parser.ts | 131 ++++++ packages/cli/src/permissions-command.ts | 180 ++++++++ .../__tests__/runtime-policy-codec.test.ts | 66 +++ packages/core/src/runtime-policy.ts | 22 + .../src/runtime-policy/permission-rules.ts | 191 ++++++++ .../core/src/runtime-policy/policy-codec.ts | 48 ++- .../src/__tests__/execution-host.test.ts | 29 ++ .../src/server/execution-model-composition.ts | 2 + .../src/__tests__/ai-sdk-backend.test.ts | 31 ++ .../src/__tests__/apply-patch-profile.test.ts | 20 + .../pre-dispatch-refusal-ledger.test.ts | 257 ++++++++++- packages/runtime/src/ai-sdk-backend.ts | 13 +- packages/runtime/src/apply-patch-profile.ts | 6 +- packages/runtime/src/runtime-kernel.ts | 3 + packages/runtime/src/session-manager.ts | 13 +- packages/runtime/src/tool-runtime.ts | 406 ++++++++++++++++++ .../__tests__/runtime-policy-stores.test.ts | 42 +- .../src/runtime-policy/policy-document.ts | 15 +- 23 files changed, 1648 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/__tests__/permissions-command.test.ts create mode 100644 packages/cli/src/permissions-command-parser.ts create mode 100644 packages/cli/src/permissions-command.ts create mode 100644 packages/core/src/runtime-policy/permission-rules.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 0595257399..b93878908f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -101,6 +101,24 @@ Maka asks before privileged tool operations by default. `maka run --yolo` grants and network access and should only be used in an environment you are prepared to let the task modify. +## Persistent permission rules + +The Runtime Host can persist explicit deny rules for commands and filesystem paths. They are checked +before tool dispatch, including when a turn uses `--yolo`: + +```sh +maka permissions deny-command 'git commit *' +maka permissions deny-command 'git push *' +maka permissions deny-path /mnt --scope subtree +maka permissions deny-path /etc/wsl.conf --scope exact +maka permissions list +``` + +Use `remove-command` or `remove-path` with the same value and scope to remove a rule. Paths must be +absolute. Command patterns use glob matching (`*` and `?`), not regular expressions. Unmatched +operations continue to use the Session permission mode and sandbox. To manage a different local or +remote Runtime Host, pass `--root ` and, where applicable, `--host `. + ## Upgrade While using prereleases, keep the `next` tag explicit: diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index 2558c0373b..e9e5c55975 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -94,6 +94,24 @@ maka run --help Maka 默认会在执行高权限工具操作前询问。`maka run --yolo` 会授予该任务完整的文件和网络 权限,只应在你允许任务修改的环境中使用。 +## 持久化权限拒绝规则 + +Runtime Host 支持为命令和文件路径持久化显式拒绝规则。规则会在工具真正执行前检查, +即使 Turn 使用了 `--yolo` 也仍然生效: + +```sh +maka permissions deny-command 'git commit *' +maka permissions deny-command 'git push *' +maka permissions deny-path /mnt --scope subtree +maka permissions deny-path /etc/wsl.conf --scope exact +maka permissions list +``` + +删除规则时,使用相同值和 scope 的 `remove-command` 或 `remove-path`。路径必须是绝对路径; +命令模式使用 glob 匹配(`*` 和 `?`),不是正则表达式。没有匹配规则的操作继续遵循当前 +Session 的 permission mode 和 sandbox。要管理其他本地或远程 Runtime Host,可传入 +`--root `,并在需要时传入 `--host `。 + ## 升级 使用预发布版本时,请继续明确指定 `next`: diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 09ed0228b9..de781d421d 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -51,6 +51,7 @@ describe('Maka CLI args', () => { assert.match(help.text, /^ maka run /m); assert.match(help.text, /^ maka activate /m); assert.match(help.text, /^ maka eval /m); + assert.match(help.text, /^ maka permissions list /m); assert.match(help.text, /^ maka update --target /m); assert.match( help.text, diff --git a/packages/cli/src/__tests__/permissions-command.test.ts b/packages/cli/src/__tests__/permissions-command.test.ts new file mode 100644 index 0000000000..779f909daa --- /dev/null +++ b/packages/cli/src/__tests__/permissions-command.test.ts @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createDefaultRuntimePolicy, type RuntimePolicySnapshot } from '@maka/core/runtime-policy'; +import type { RuntimeHostCliConnectionContext } from '../runtime-host-cli-context.js'; +import { parsePermissionsCommand } from '../permissions-command-parser.js'; +import { runPermissionsCli, updatePermissionRules } from '../permissions-command.js'; + +test('parses permission list and mutation commands', () => { + assert.deepEqual( + parsePermissionsCommand([ + 'deny-command', + 'git push *', + '--root', + '/srv/maka', + '--host', + 'office', + ]), + { + kind: 'permissions', + action: { kind: 'deny-command', pattern: 'git push *' }, + rootPath: '/srv/maka', + hostProfileId: 'office', + }, + ); + assert.deepEqual(parsePermissionsCommand(['deny-path', '/mnt/**', '--scope', 'subtree']), { + kind: 'permissions', + action: { kind: 'deny-path', path: '/mnt', scope: 'subtree' }, + }); + assert.deepEqual(parsePermissionsCommand(['list', '--scope', 'exact']), { + kind: 'error', + message: 'permissions list does not accept a path or --scope', + exitCode: 2, + }); + assert.deepEqual(parsePermissionsCommand(['remove-path', 'relative', '--scope', 'exact']), { + kind: 'error', + message: 'permissions remove-path requires an absolute path', + exitCode: 2, + }); +}); + +test('updates permission rules canonically and removes normalized paths', () => { + const current = updatePermissionRules( + { denyCommands: [], denyPaths: [] }, + { kind: 'deny-path', path: '/mnt/', scope: 'subtree' }, + ); + assert.deepEqual(current, { + denyCommands: [], + denyPaths: [{ path: '/mnt', scope: 'subtree' }], + }); + assert.deepEqual( + updatePermissionRules(current, { kind: 'remove-path', path: '/mnt/', scope: 'subtree' }), + { denyCommands: [], denyPaths: [] }, + ); +}); + +test('queries and CAS-mutates the Host-owned permission rules', async () => { + const requests: { operation: string; input: unknown }[] = []; + const initial: RuntimePolicySnapshot = { + revision: 7, + policy: createDefaultRuntimePolicy(), + }; + const context = { + connection: { + request: async (operation: string, input: unknown) => { + requests.push({ operation, input }); + if (operation === 'runtime.policy.query') return initial; + return { kind: 'committed', revision: 8 }; + }, + }, + close: async () => {}, + } as unknown as RuntimeHostCliConnectionContext; + let output = ''; + const exitCode = await runPermissionsCli( + { + kind: 'permissions', + action: { kind: 'deny-command', pattern: 'git commit *' }, + }, + { defaultRootPath: '/state', clientDataRoot: '/client' }, + { + connect: async (input) => { + assert.deepEqual(input, { + rootPath: '/state', + clientDataRoot: '/client', + }); + return context; + }, + write: (value) => { + output += value; + }, + }, + ); + assert.equal(exitCode, 0); + assert.deepEqual(requests, [ + { operation: 'runtime.policy.query', input: {} }, + { + operation: 'runtime.policy.mutate', + input: { + expectedRevision: 7, + operation: { + kind: 'set_permission_rules', + value: { denyCommands: ['git commit *'], denyPaths: [] }, + }, + }, + }, + ]); + assert.deepEqual(JSON.parse(output), { + revision: 8, + denyCommands: ['git commit *'], + denyPaths: [], + }); +}); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index b7cceffd30..e9cbecd4d2 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -30,6 +30,10 @@ import { parseRuntimeHostInstalledUpdateCommand, type RuntimeHostCliCommand, } from './runtime-host-cli.js'; +import { + parsePermissionsCommand, + type PermissionCliCommand, +} from './permissions-command-parser.js'; import { resolveCliUiLocale } from './cli-ui-locale.js'; export type MakaCliCommand = @@ -44,6 +48,7 @@ export type MakaCliCommand = | { kind: 'activate'; args: string[] } | { kind: 'eval'; args: string[] } | { kind: 'acp' } + | PermissionCliCommand | RuntimeHostCliCommand | { kind: 'help'; text: string } | { kind: 'version'; text: string } @@ -84,6 +89,7 @@ export function parseMakaCliArgs( if (first === 'run' || first === '-p') return { kind: 'run', args: argv.slice(1) }; if (first === 'activate') return { kind: 'activate', args: argv.slice(1) }; if (first === 'eval') return { kind: 'eval', args: argv.slice(1) }; + if (first === 'permissions') return parsePermissionsCommand(argv.slice(1)); if (first === 'update') return parseRuntimeHostInstalledUpdateCommand(argv.slice(1)); if (first === 'runtime-host') return parseRuntimeHostCommand(argv.slice(1)); return { @@ -138,6 +144,11 @@ function helpText(cliCommand: string): string { ` ${cliCommand} activate ... Run one Cloud Session activation and emit JSONL`, ` ${cliCommand} -p ... Alias for ${cliCommand} run`, ` ${cliCommand} eval ... Run one declarative multi-arm experiment`, + ` ${cliCommand} permissions list [--root ] [--host ]`, + ` ${cliCommand} permissions deny-command [--root ] [--host ]`, + ` ${cliCommand} permissions deny-path --scope [--root ] [--host ]`, + ` ${cliCommand} permissions remove-command [--root ] [--host ]`, + ` ${cliCommand} permissions remove-path --scope [--root ] [--host ]`, ` ${cliCommand} update --target Update this npm-global CLI and its local Runtime Host`, ` ${cliCommand} runtime-host serve [options] Run a Runtime Host service`, ` ${cliCommand} runtime-host activate --framed --root-id `, @@ -299,6 +310,13 @@ export async function runMakaCli( const { runMakaEvalCli } = await import('@maka/eval'); return runMakaEvalCli(command.args); } + case 'permissions': { + const { runPermissionsCli } = await import('./permissions-command.js'); + return runPermissionsCli(command, { + defaultRootPath: dataRoots.workspaceRoot, + clientDataRoot: dataRoots.clientDataRoot, + }); + } case 'acp': { const { runMakaAcpStdioServer } = await import('./acp/stdio-server.js'); return runMakaAcpStdioServer({ diff --git a/packages/cli/src/permissions-command-parser.ts b/packages/cli/src/permissions-command-parser.ts new file mode 100644 index 0000000000..6f3dc48204 --- /dev/null +++ b/packages/cli/src/permissions-command-parser.ts @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isAbsolute } from 'node:path'; +import { + canonicalizePermissionPathRule, + type PermissionCliAction, + type PermissionCliCommand, +} from './permissions-command.js'; + +export type { PermissionCliCommand } from './permissions-command.js'; + +export type PermissionCliParseResult = + | PermissionCliCommand + | { readonly kind: 'error'; readonly message: string; readonly exitCode: 2 }; + +export function parsePermissionsCommand(argv: string[]): PermissionCliParseResult { + const actionName = argv[0]; + if ( + actionName !== 'list' && + actionName !== 'deny-command' && + actionName !== 'deny-path' && + actionName !== 'remove-command' && + actionName !== 'remove-path' + ) { + return error( + actionName + ? `Unexpected permissions command: ${actionName}` + : 'permissions requires list, deny-command, deny-path, remove-command, or remove-path', + ); + } + + let rootPath: string | undefined; + let hostProfileId: string | undefined; + let positional: string | undefined; + let scope: 'exact' | 'subtree' | undefined; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--root' || argument === '--host' || argument === '--scope') { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) return error(`${argument} requires a value`); + index += 1; + if (argument === '--root') { + if (rootPath !== undefined) return error('Duplicate --root'); + if (!isAbsolute(value) || /[\u0000-\u001f\u007f]/u.test(value)) { + return error('--root must be an absolute path'); + } + rootPath = value; + } else if (argument === '--host') { + if (hostProfileId !== undefined) return error('Duplicate --host'); + hostProfileId = value; + } else { + if (scope !== undefined) return error('Duplicate --scope'); + if (value !== 'exact' && value !== 'subtree') { + return error('--scope must be exact or subtree'); + } + scope = value; + } + continue; + } + if (argument?.startsWith('-')) return error(`Unexpected permissions option: ${argument}`); + if (positional !== undefined) return error(`Unexpected argument: ${argument ?? ''}`); + positional = argument; + } + + const pathAction = actionName === 'deny-path' || actionName === 'remove-path'; + if (actionName === 'list') { + if (positional !== undefined || scope !== undefined) { + return error('permissions list does not accept a path or --scope'); + } + return { + kind: 'permissions', + action: { kind: 'list' }, + ...locationOptions(rootPath, hostProfileId), + }; + } + if (positional === undefined || positional.length === 0) { + return error(`permissions ${actionName} requires a value`); + } + if (pathAction) { + if ( + (!isAbsolute(positional) && !/^[A-Za-z]:\\/u.test(positional)) || + /[\u0000-\u001f\u007f]/u.test(positional) + ) { + return error(`permissions ${actionName} requires an absolute path`); + } + if (!scope) return error(`permissions ${actionName} requires --scope `); + } else if (scope !== undefined) { + return error(`permissions ${actionName} does not accept --scope`); + } + + let action: PermissionCliAction; + if (pathAction) { + try { + const rule = canonicalizePermissionPathRule(positional, scope!); + action = { kind: actionName, path: rule.path, scope: rule.scope }; + } catch (cause) { + return error(cause instanceof Error ? cause.message : String(cause)); + } + } else { + action = { kind: actionName, pattern: positional }; + } + return { kind: 'permissions', action, ...locationOptions(rootPath, hostProfileId) }; +} + +function locationOptions(rootPath: string | undefined, hostProfileId: string | undefined) { + return { + ...(rootPath === undefined ? {} : { rootPath }), + ...(hostProfileId === undefined ? {} : { hostProfileId }), + }; +} + +function error(message: string): PermissionCliParseResult { + return { kind: 'error', message, exitCode: 2 }; +} diff --git a/packages/cli/src/permissions-command.ts b/packages/cli/src/permissions-command.ts new file mode 100644 index 0000000000..dcb9279ccc --- /dev/null +++ b/packages/cli/src/permissions-command.ts @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + normalizePermissionRules, + samePermissionPath, + type PermissionPathRule, + type PermissionRules, + type RuntimePolicySnapshot, +} from '@maka/core/runtime-policy'; +import { + connectRuntimeHostCli, + type RuntimeHostCliConnectionContext, +} from './runtime-host-cli-context.js'; + +export type PermissionCliAction = + | { readonly kind: 'list' } + | { readonly kind: 'deny-command'; readonly pattern: string } + | { + readonly kind: 'deny-path'; + readonly path: string; + readonly scope: 'exact' | 'subtree'; + } + | { readonly kind: 'remove-command'; readonly pattern: string } + | { + readonly kind: 'remove-path'; + readonly path: string; + readonly scope: 'exact' | 'subtree'; + }; + +export interface PermissionCliCommand { + readonly kind: 'permissions'; + readonly action: PermissionCliAction; + readonly rootPath?: string; + readonly hostProfileId?: string; +} + +export interface PermissionCliDeps { + readonly connect: (input: { + readonly rootPath: string; + readonly profileId?: string; + readonly clientDataRoot: string; + }) => Promise; + readonly write: (value: string) => void; +} + +export interface PermissionCliOptions { + readonly defaultRootPath: string; + readonly clientDataRoot: string; +} + +export async function runPermissionsCli( + command: PermissionCliCommand, + options: PermissionCliOptions, + overrides: Partial = {}, +): Promise { + const deps = { ...defaultDeps(), ...overrides }; + const context = await deps.connect({ + rootPath: command.rootPath ?? options.defaultRootPath, + clientDataRoot: options.clientDataRoot, + ...(command.hostProfileId ? { profileId: command.hostProfileId } : {}), + }); + try { + const snapshot = await context.connection.request('runtime.policy.query', {}); + if (command.action.kind === 'list') { + deps.write(`${JSON.stringify(projectPermissionRules(snapshot), null, 2)}\n`); + return 0; + } + + const nextRules = updatePermissionRules(snapshot.policy.permissionRules, command.action); + const result = await context.connection.request('runtime.policy.mutate', { + expectedRevision: snapshot.revision, + operation: { kind: 'set_permission_rules', value: nextRules }, + }); + if (result.kind === 'revision_conflict') { + throw new Error( + `Runtime Policy changed while updating permissions (expected revision ${result.expectedRevision}, actual ${result.actualRevision}); re-run the command`, + ); + } + deps.write(`${JSON.stringify({ revision: result.revision, ...nextRules }, null, 2)}\n`); + return 0; + } finally { + await context.close(); + } +} + +export function updatePermissionRules( + current: PermissionRules, + action: Exclude, +): PermissionRules { + const next = { + denyCommands: [...current.denyCommands], + denyPaths: current.denyPaths.map((rule) => ({ ...rule })), + }; + const canonicalAction = canonicalizePermissionAction(action); + switch (action.kind) { + case 'deny-command': + next.denyCommands.push(canonicalAction.pattern); + break; + case 'deny-path': + next.denyPaths.push(canonicalAction.rule); + break; + case 'remove-command': + next.denyCommands = next.denyCommands.filter( + (pattern) => pattern !== canonicalAction.pattern, + ); + break; + case 'remove-path': + next.denyPaths = next.denyPaths.filter( + (rule) => + !samePermissionPath(rule.path, canonicalAction.rule.path) || + rule.scope !== canonicalAction.rule.scope, + ); + break; + } + return normalizePermissionRules(next); +} + +export function canonicalizePermissionPathRule( + path: string, + scope: 'exact' | 'subtree', +): PermissionPathRule { + const globSuffix = /[\\/]\*\*$/.test(path); + if (globSuffix) { + if (scope !== 'subtree') { + throw new Error('A path ending in /** must use --scope subtree'); + } + path = path.slice(0, -2); + } + return normalizePermissionRules({ + denyCommands: [], + denyPaths: [{ path, scope }], + }).denyPaths[0]!; +} + +function canonicalizePermissionAction( + action: Exclude, +): { readonly pattern: string; readonly rule: PermissionPathRule } { + if (action.kind === 'deny-command' || action.kind === 'remove-command') { + return { + pattern: normalizePermissionRules({ denyCommands: [action.pattern], denyPaths: [] }) + .denyCommands[0]!, + rule: { path: '/', scope: 'exact' }, + }; + } + return { + pattern: '', + rule: canonicalizePermissionPathRule(action.path, action.scope), + }; +} + +function projectPermissionRules(snapshot: RuntimePolicySnapshot) { + return { + revision: snapshot.revision, + ...snapshot.policy.permissionRules, + }; +} + +function defaultDeps(): PermissionCliDeps { + return { + connect: (input) => connectRuntimeHostCli(input), + write: (value) => process.stdout.write(value), + }; +} diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index d6aba85a60..a13d062fc3 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -23,6 +23,9 @@ import { createDefaultRuntimePolicy, decodeCanonicalConnectionCatalogEntry, decodeCanonicalRuntimePolicy, + decodeCanonicalPermissionRules, + matchPermissionRules, + normalizePermissionRules, decodeRelayModelProfilesTable, normalizeCreateCatalogConnectionInput, normalizeConnectionCatalogEntryUpdate, @@ -33,6 +36,69 @@ import { RuntimePolicyDomainDecodeError, } from '../runtime-policy.js'; +test('normalizes and matches persistent deny rules without treating patterns as regexes', () => { + const rules = normalizePermissionRules({ + denyCommands: ['git push *', 'git commit *'], + denyPaths: [ + { path: '/mnt', scope: 'subtree' }, + { path: '/etc/wsl.conf', scope: 'exact' }, + ], + }); + assert.deepEqual(rules, { + denyCommands: ['git commit *', 'git push *'], + denyPaths: [ + { path: '/etc/wsl.conf', scope: 'exact' }, + { path: '/mnt', scope: 'subtree' }, + ], + }); + assert.equal(matchPermissionRules(rules, { command: 'git push origin main' })?.kind, 'command'); + assert.equal(matchPermissionRules(rules, { command: 'git push origin\nmain' })?.kind, 'command'); + assert.equal(matchPermissionRules(rules, { command: 'git pull origin main' }), undefined); + assert.equal(matchPermissionRules(rules, { path: '/mnt/project/file.txt' })?.kind, 'path'); + assert.equal(matchPermissionRules(rules, { path: '/mnt-other/file.txt' }), undefined); + assert.equal(matchPermissionRules(rules, { path: '/etc/wsl.conf' })?.kind, 'path'); + assert.equal(matchPermissionRules(rules, { path: '/etc/wsl.conf.d/extra' }), undefined); +}); + +test('normalizes permission-rule mutations and rejects unsafe path rules', () => { + const mutation = normalizeRuntimePolicyMutation({ + expectedRevision: 0, + operation: { + kind: 'set_permission_rules', + value: { + denyCommands: [' git commit * ', 'git commit *'], + denyPaths: [{ path: '/tmp/', scope: 'subtree' }], + }, + }, + }); + assert.deepEqual(mutation.operation, { + kind: 'set_permission_rules', + value: { denyCommands: ['git commit *'], denyPaths: [{ path: '/tmp', scope: 'subtree' }] }, + }); + assert.throws( + () => + normalizeRuntimePolicyMutation({ + expectedRevision: 0, + operation: { + kind: 'set_permission_rules', + value: { denyCommands: [], denyPaths: [{ path: 'etc/passwd', scope: 'exact' }] }, + }, + }), + RuntimePolicyDomainDecodeError, + ); + assert.throws( + () => + normalizeRuntimePolicyMutation({ + expectedRevision: 0, + operation: { + kind: 'set_permission_rules', + value: { denyCommands: [], denyPaths: [{ path: '/tmp/../etc', scope: 'subtree' }] }, + }, + }), + RuntimePolicyDomainDecodeError, + ); +}); + test('normalizes policy input while canonical policy decode rejects producer drift', () => { const mutation = normalizeRuntimePolicyMutation({ expectedRevision: 0, diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index c99e2d041c..34f9cf215d 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -35,6 +35,7 @@ import { } from './settings.js'; import type { SubagentSettings } from './subagent-settings.js'; import type { JsonObject } from './request-customization.js'; +import { EMPTY_PERMISSION_RULES, type PermissionRules } from './runtime-policy/permission-rules.js'; import { WEB_SEARCH_PROVIDERS, type WebSearchCredentialProvider, @@ -54,8 +55,26 @@ export { normalizeNetworkProxyCredentialTarget, decodeRuntimePolicyV2, normalizeNetworkProxyUpdate, + decodeRuntimePolicyV3, normalizeRuntimePolicyMutation, } from './runtime-policy/policy-codec.js'; +export { + decodeCanonicalPermissionRules, + EMPTY_PERMISSION_RULES, + matchPermissionRules, + normalizePermissionRules, + permissionPathWithinRoot, + samePermissionPath, + PERMISSION_RULES_MAX_COMMANDS, + PERMISSION_RULES_MAX_PATHS, + PERMISSION_RULE_MAX_COMMAND_LENGTH, + PERMISSION_RULE_MAX_PATH_LENGTH, +} from './runtime-policy/permission-rules.js'; +export type { + PermissionPathRule, + PermissionRuleMatch, + PermissionRules, +} from './runtime-policy/permission-rules.js'; export { CONNECTION_CATALOG_MAX_CONNECTIONS, CONNECTION_CATALOG_MAX_ENABLED_MODEL_IDS, @@ -160,6 +179,7 @@ export interface RuntimePolicy { }; readonly subagents: SubagentSettings; readonly shell: ShellSettings; + readonly permissionRules: PermissionRules; } export interface RuntimePolicySnapshot { @@ -188,6 +208,7 @@ export type RuntimePolicyMutation = | { readonly kind: 'set_web_search'; readonly value: RuntimePolicy['webSearch'] } | { readonly kind: 'set_subagents'; readonly value: RuntimePolicy['subagents'] } | { readonly kind: 'set_shell'; readonly value: RuntimePolicy['shell'] } + | { readonly kind: 'set_permission_rules'; readonly value: RuntimePolicy['permissionRules'] } | { readonly kind: 'patch_agent_settings'; readonly value: AgentRuntimeSettingsPatch }; export interface MutateRuntimePolicyInput { @@ -257,6 +278,7 @@ export function createDefaultRuntimePolicy(): RuntimePolicy { webSearch: { enabled: false, defaultProvider: 'model' }, subagents: { presets: [] }, shell: { preference: 'auto', executable: '' }, + permissionRules: EMPTY_PERMISSION_RULES, }; } diff --git a/packages/core/src/runtime-policy/permission-rules.ts b/packages/core/src/runtime-policy/permission-rules.ts new file mode 100644 index 0000000000..5d983e4abd --- /dev/null +++ b/packages/core/src/runtime-policy/permission-rules.ts @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + isNormalizedAbsolutePath, + pathWithinRoot, + samePath, + trimTrailingPathSeparators, +} from '../absolute-path.js'; +import { assertCanonicalValue, domainError, exactRecord, stringValue } from './domain-codec.js'; + +export { pathWithinRoot as permissionPathWithinRoot } from '../absolute-path.js'; + +export const PERMISSION_RULES_MAX_COMMANDS = 128; +export const PERMISSION_RULES_MAX_PATHS = 128; +export const PERMISSION_RULE_MAX_COMMAND_LENGTH = 4_096; +export const PERMISSION_RULE_MAX_PATH_LENGTH = 4_096; + +export interface PermissionPathRule { + readonly path: string; + readonly scope: 'exact' | 'subtree'; +} + +export interface PermissionRules { + readonly denyCommands: readonly string[]; + readonly denyPaths: readonly PermissionPathRule[]; +} + +export type PermissionRuleMatch = + | { readonly kind: 'command'; readonly pattern: string } + | { readonly kind: 'path'; readonly rule: PermissionPathRule }; + +export const EMPTY_PERMISSION_RULES: PermissionRules = Object.freeze({ + denyCommands: Object.freeze([]), + denyPaths: Object.freeze([]), +}); + +export function normalizePermissionRules(value: unknown): PermissionRules { + const item = exactRecord(value, 'permission rules', ['denyCommands', 'denyPaths']); + if ( + !Array.isArray(item.denyCommands) || + item.denyCommands.length > PERMISSION_RULES_MAX_COMMANDS + ) { + throw domainError( + `permission rules denyCommands must contain no more than ${PERMISSION_RULES_MAX_COMMANDS} entries`, + ); + } + if (!Array.isArray(item.denyPaths) || item.denyPaths.length > PERMISSION_RULES_MAX_PATHS) { + throw domainError( + `permission rules denyPaths must contain no more than ${PERMISSION_RULES_MAX_PATHS} entries`, + ); + } + + const denyCommands = [ + ...new Set(item.denyCommands.map((value, index) => normalizeCommandPattern(value, index))), + ].sort((left, right) => left.localeCompare(right)); + const denyPaths = item.denyPaths + .map((value, index) => normalizePathRule(value, index)) + .filter( + (rule, index, rules) => + rules.findIndex( + (candidate) => candidate.scope === rule.scope && samePath(candidate.path, rule.path), + ) === index, + ) + .sort(comparePathRules); + + return { denyCommands, denyPaths }; +} + +export function decodeCanonicalPermissionRules(value: unknown): PermissionRules { + const decoded = normalizePermissionRules(value); + assertCanonicalValue(value, decoded, 'permission rules'); + return decoded; +} + +export function matchPermissionRules( + rules: PermissionRules, + request: { readonly command?: string; readonly path?: string }, +): PermissionRuleMatch | undefined { + if (request.command !== undefined) { + for (const pattern of rules.denyCommands) { + if (globMatches(pattern, request.command)) return { kind: 'command', pattern }; + } + } + if (request.path !== undefined) { + for (const rule of rules.denyPaths) { + if ( + rule.scope === 'exact' + ? samePath(rule.path, request.path) + : pathWithinRoot(request.path, rule.path) + ) { + return { kind: 'path', rule }; + } + } + } + return undefined; +} + +/** Compare canonical permission paths using the platform-aware path rules. */ +export function samePermissionPath(left: string, right: string): boolean { + return samePath(left, right); +} + +function normalizeCommandPattern(value: unknown, index: number): string { + const pattern = stringValue( + value, + `permission rules denyCommands[${index}]`, + PERMISSION_RULE_MAX_COMMAND_LENGTH, + ).trim(); + if (pattern.length === 0) { + throw domainError(`permission rules denyCommands[${index}] must not be empty`); + } + if (/[^\x20-\x7e]/.test(pattern)) { + throw domainError( + `permission rules denyCommands[${index}] must contain printable characters only`, + ); + } + return pattern; +} + +function normalizePathRule(value: unknown, index: number): PermissionPathRule { + const item = exactRecord(value, `permission rules denyPaths[${index}]`, ['path', 'scope']); + const rawPath = stringValue( + item.path, + `permission rules denyPaths[${index}].path`, + PERMISSION_RULE_MAX_PATH_LENGTH, + ).trim(); + const path = trimTrailingPathSeparators(rawPath); + if (!isNormalizedAbsolutePath(path)) { + throw domainError( + `permission rules denyPaths[${index}].path must be a normalized absolute path`, + ); + } + if (item.scope !== 'exact' && item.scope !== 'subtree') { + throw domainError(`permission rules denyPaths[${index}].scope is invalid`); + } + return { path, scope: item.scope }; +} + +function comparePathRules(left: PermissionPathRule, right: PermissionPathRule): number { + return ( + left.path.localeCompare(right.path) || + (left.scope === right.scope ? 0 : left.scope === 'subtree' ? -1 : 1) + ); +} + +function globMatches(pattern: string, value: string): boolean { + let source = '^'; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]!; + if (character === '*') { + source += '[\\s\\S]*'; + } else if (character === '?') { + source += '[\\s\\S]'; + } else if (character === '[') { + const close = pattern.indexOf(']', index + 1); + if (close > index + 1) { + const body = pattern.slice(index + 1, close); + if (/^[^\\\]]+$/.test(body)) { + source += `[${body.replace(/[-^]/g, '\\$&')}]`; + index = close; + continue; + } + } + source += '\\['; + } else { + source += escapeRegExp(character); + } + } + source += '$'; + return new RegExp(source).test(value); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/core/src/runtime-policy/policy-codec.ts b/packages/core/src/runtime-policy/policy-codec.ts index 3462dbc01b..45623683c4 100644 --- a/packages/core/src/runtime-policy/policy-codec.ts +++ b/packages/core/src/runtime-policy/policy-codec.ts @@ -20,6 +20,11 @@ import { isThinkingLevel } from '../model-thinking.js'; import { CHAT_DEFAULT_PERMISSION_MODES } from '../settings.js'; import { normalizeSubagentSettings } from '../subagent-settings.js'; +import { + decodeCanonicalPermissionRules, + EMPTY_PERMISSION_RULES, + normalizePermissionRules, +} from './permission-rules.js'; import type { AgentRuntimeSettingsPatch, MutateRuntimePolicyInput, @@ -67,8 +72,32 @@ export function decodeRuntimePolicyV2(value: unknown): RuntimePolicy { policy, normalizeSubagentSettings(policy.subagents), { preference: 'auto', executable: '' }, + EMPTY_PERMISSION_RULES, + ); + assertCanonicalValue(value, withoutShellAndPermissionRules(decoded), 'runtime policy v2'); + return decoded; +} + +/** Upgrade the immediately previous canonical document with empty permission rules. */ +export function decodeRuntimePolicyV3(value: unknown): RuntimePolicy { + const policy = exactRecord(value, 'runtime policy v3', [ + 'networkProxy', + 'personalization', + 'memory', + 'workspaceInstructions', + 'privacy', + 'chatDefaults', + 'webSearch', + 'subagents', + 'shell', + ]); + const decoded = normalizeRuntimePolicyFields( + policy, + normalizeSubagentSettings(policy.subagents), + normalizeShell(policy.shell), + EMPTY_PERMISSION_RULES, ); - assertCanonicalValue(value, withoutShell(decoded), 'runtime policy v2'); + assertCanonicalValue(value, withoutPermissionRules(decoded), 'runtime policy v3'); return decoded; } @@ -185,11 +214,13 @@ function normalizeRuntimePolicy(value: unknown): RuntimePolicy { 'webSearch', 'subagents', 'shell', + 'permissionRules', ]); return normalizeRuntimePolicyFields( policy, normalizeSubagentSettings(policy.subagents), normalizeShell(policy.shell), + decodeCanonicalPermissionRules(policy.permissionRules), ); } @@ -197,6 +228,7 @@ function normalizeRuntimePolicyFields( policy: Record, subagents: RuntimePolicy['subagents'], shell: RuntimePolicy['shell'], + permissionRules: RuntimePolicy['permissionRules'], ): RuntimePolicy { return { networkProxy: normalizeNetworkProxy(policy.networkProxy), @@ -208,11 +240,19 @@ function normalizeRuntimePolicyFields( webSearch: normalizeWebSearch(policy.webSearch), subagents, shell, + permissionRules, }; } -function withoutShell(policy: RuntimePolicy): Omit { - const { shell: _shell, ...legacy } = policy; +function withoutShellAndPermissionRules( + policy: RuntimePolicy, +): Omit { + const { shell: _shell, permissionRules: _permissionRules, ...legacy } = policy; + return legacy; +} + +function withoutPermissionRules(policy: RuntimePolicy): Omit { + const { permissionRules: _permissionRules, ...legacy } = policy; return legacy; } @@ -236,6 +276,8 @@ function normalizeMutationOperation(operation: Record): Runtime return { kind: operation.kind, value: normalizeSubagentSettings(operation.value) }; case 'set_shell': return { kind: operation.kind, value: normalizeShell(operation.value) }; + case 'set_permission_rules': + return { kind: operation.kind, value: normalizePermissionRules(operation.value) }; case 'patch_agent_settings': return { kind: operation.kind, value: normalizeAgentRuntimeSettingsPatch(operation.value) }; default: diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index 096479011b..58b2047881 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -522,6 +522,35 @@ test('two UDS Clients share one Runtime Policy authority and CAS winner', async }); }); +test('transports and persists permission-rule mutations through the Runtime Host protocol', async () => { + await withExecutionRoot(async (fixture) => { + const host = await fixture.startHost(); + const client = await connectClient(fixture.root); + try { + const initial = await client.request('runtime.policy.query', {}); + const committed = await client.request('runtime.policy.mutate', { + expectedRevision: initial.revision, + operation: { + kind: 'set_permission_rules', + value: { + denyCommands: ['git commit *'], + denyPaths: [{ path: '/mnt', scope: 'subtree' }], + }, + }, + }); + assert.deepEqual(committed, { kind: 'committed', revision: initial.revision + 1 }); + const queried = await client.request('runtime.policy.query', {}); + assert.deepEqual(queried.policy.permissionRules, { + denyCommands: ['git commit *'], + denyPaths: [{ path: '/mnt', scope: 'subtree' }], + }); + } finally { + await client.close(); + await fixture.stopHost(host); + } + }); +}); + test('two UDS Clients serialize same-provider account creation through one Host lane', async () => { const provider = await startConnectionEffectProvider({ responseDelayMs: 50 }); try { diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index cc495950e2..559d561a08 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -364,6 +364,8 @@ async function buildHostAiSdkBackend( input.context.store.settleSandboxBoundaryRequest!(request), } : {}), + permissionRules: runtimePolicySnapshot.policy.permissionRules, + permissionRuntimeState: input.context.permissionRuntimeState, connection: target.connection, providerStateIdentity: target.providerStateIdentity, apiKey, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 055875101e..8d95be63c6 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -165,6 +165,37 @@ describe('AiSdkBackend ApplyPatch routing', () => { assert.equal(names.includes('Edit'), true); }); + test('hides native apply_patch when persistent path denies are active', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [ + nativeApplyPatchTool(), + testTool('Write', z.object({})), + testTool('Edit', z.object({})), + ], + permissionRules: { + denyCommands: [], + denyPaths: [{ path: '/mnt', scope: 'subtree' }], + }, + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'edit', context: [] })); + + const names = modelToolNames(model); + assert.equal(names.includes('apply_patch'), false); + assert.equal(names.includes('Write'), true); + assert.equal(names.includes('Edit'), true); + }); + test('replays a durable apply_patch failure as native provider JSON', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ diff --git a/packages/runtime/src/__tests__/apply-patch-profile.test.ts b/packages/runtime/src/__tests__/apply-patch-profile.test.ts index 820bb327dc..7c229758c7 100644 --- a/packages/runtime/src/__tests__/apply-patch-profile.test.ts +++ b/packages/runtime/src/__tests__/apply-patch-profile.test.ts @@ -68,6 +68,26 @@ describe('ApplyPatch profile routing', () => { ); }); + test('keeps client-side file tools when persistent path denies are active', () => { + const tool = (name: string, providerTool?: MakaTool['providerTool']): MakaTool => ({ + name, + description: name, + parameters: {}, + providerTool, + impl: async () => undefined, + }); + const routed = routeApplyPatchTools( + [tool('Write'), tool('Edit'), tool('apply_patch', { kind: 'openai-apply-patch' })], + { kind: 'openai-structured' }, + false, + ); + + assert.deepEqual( + routed.map(({ name }) => name), + ['Write', 'Edit'], + ); + }); + test('does not expose the dormant Codex V4A freeform target path', () => { assert.equal( resolveApplyPatchProfile( diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index 5207802f61..f4a0a89b83 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -19,6 +19,7 @@ import { nextId } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; import { test } from 'node:test'; import { type LlmConnection } from '@maka/core/llm-connections'; @@ -28,6 +29,7 @@ import { type RuntimeEvent } from '@maka/core/runtime-event'; import { type SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; +import { normalizePermissionRules } from '@maka/core/runtime-policy'; import { scanToolLedger } from '@maka/core/tool-ledger-scanner'; import { z } from 'zod'; @@ -37,7 +39,12 @@ import { } from '../session-event-runtime-mapper.js'; import type { RuntimeEventMapContext } from '../session-event-runtime-mapper.js'; import type { RuntimeCommitSink } from '../runtime-commit-sink.js'; -import { LOOP_GATE_IDENTICAL_THRESHOLD, type MakaTool, type ToolRuntime } from '../tool-runtime.js'; +import { + LOOP_GATE_IDENTICAL_THRESHOLD, + type MakaTool, + type ToolRuntime, + createPermissionRuntimeState, +} from '../tool-runtime.js'; import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; /** @@ -323,6 +330,62 @@ const REFUSAL_PATHS: Array<{ return settle(h, clientCapabilityTool(), {}, { toolCallId: 'call_boundary_blocked' }); }, }, + { + name: 'persistent command deny outranks bypass mode', + expect: /persistent permission rule/, + drive: (h) => { + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: ['git push *'], + denyPaths: [], + }), + }); + const tool: MakaTool = { + name: 'Bash', + description: 'test', + parameters: z.object({ command: z.string() }), + impl: async () => assert.fail('persistent deny must prevent execution'), + }; + return settle( + h, + tool, + { command: 'git push origin main' }, + { + runtime, + toolCallId: 'call_persistent_command', + }, + ); + }, + }, + { + name: 'persistent path deny covers file tools', + expect: /persistent permission rule/, + drive: (h) => { + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: [], + denyPaths: [{ path: resolve('/workspace', 'blocked.txt'), scope: 'exact' }], + }), + }); + const tool: MakaTool = { + name: 'Read', + description: 'test', + parameters: z.object({ path: z.string() }), + impl: async () => assert.fail('persistent deny must prevent execution'), + }; + return settle( + h, + tool, + { path: 'blocked.txt' }, + { + runtime, + toolCallId: 'call_persistent_path', + }, + ); + }, + }, ]; for (const path of REFUSAL_PATHS) { @@ -347,6 +410,198 @@ for (const path of REFUSAL_PATHS) { }); } +test('persistent path denies cover literal paths used by Bash', async () => { + const cases = [ + { + command: `cat ${resolve('/mnt', 'secret.txt')}`, + denyPath: { path: resolve('/mnt'), scope: 'subtree' as const }, + }, + { + command: `echo data > ${resolve('/etc/wsl.conf')}`, + denyPath: { path: resolve('/etc/wsl.conf'), scope: 'exact' as const }, + }, + ]; + for (const [index, item] of cases.entries()) { + const h = harness(); + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: [], + denyPaths: [item.denyPath], + }), + }); + const tool: MakaTool = { + name: 'Bash', + description: 'test', + parameters: z.object({ command: z.string() }), + impl: async () => assert.fail('Bash path deny must prevent execution'), + }; + const { result } = await settle( + h, + tool, + { command: item.command }, + { runtime, toolCallId: `call_bash_path_${index}` }, + ); + assert.match((result as { error: string }).error, /persistent permission rule/); + } +}); + +test('persistent path denies cover recursive Glob and Grep search scopes', async () => { + const cases: Array<{ tool: MakaTool; input: unknown }> = [ + { + tool: { + name: 'Glob', + description: 'test', + parameters: z.object({ pattern: z.string(), cwd: z.string().optional() }), + impl: async () => assert.fail('Glob search scope deny must prevent execution'), + }, + input: { pattern: '**/*', cwd: resolve('/') }, + }, + { + tool: { + name: 'Grep', + description: 'test', + parameters: z.object({ pattern: z.string(), path: z.string().optional() }), + impl: async () => assert.fail('Grep search scope deny must prevent execution'), + }, + input: { pattern: 'secret', path: resolve('/') }, + }, + ]; + for (const [index, item] of cases.entries()) { + const h = harness(); + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: [], + denyPaths: [{ path: resolve('/mnt'), scope: 'subtree' }], + }), + }); + const { result } = await settle(h, item.tool, item.input, { + runtime, + toolCallId: `call_recursive_search_${index}`, + }); + assert.match((result as { error: string }).error, /persistent permission rule/); + } +}); + +test('persistent command denies cover split and action-based PTY input', async () => { + const h = harness(); + let implementationCalls = 0; + const permissionRuntimeState = createPermissionRuntimeState(); + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: ['git push *'], + denyPaths: [], + }), + permissionRuntimeState, + }); + const tool: MakaTool = { + name: 'WriteStdin', + description: 'test', + parameters: z.object({ + ref: z.string(), + input: z.string().optional(), + actions: z + .array( + z.object({ + type: z.string(), + text: z.string().optional(), + key: z.string().optional(), + }), + ) + .optional(), + }), + impl: async () => { + implementationCalls += 1; + return { ok: true }; + }, + }; + const ref = 'maka://runtime/background-tasks/pty-1'; + await settle(h, tool, { ref, input: 'git ' }, { runtime, toolCallId: 'call_pty_prefix' }); + const rebuiltRuntime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: ['git push *'], + denyPaths: [], + }), + permissionRuntimeState, + }); + const { result } = await settle( + h, + tool, + { + ref, + actions: [ + { type: 'text', text: 'push origin main' }, + { type: 'key', key: 'enter' }, + ], + }, + { runtime: rebuiltRuntime, toolCallId: 'call_pty_denied' }, + ); + assert.match((result as { error: string }).error, /persistent permission rule/); + assert.equal(implementationCalls, 1); + + const pathRuntime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: [], + denyPaths: [{ path: resolve('/mnt'), scope: 'subtree' }], + }), + }); + const pathResult = await settle( + h, + tool, + { ref, input: `cat ${resolve('/mnt', 'secret.txt')}\n` }, + { runtime: pathRuntime, toolCallId: 'call_pty_path_denied' }, + ); + assert.match((pathResult.result as { error: string }).error, /persistent permission rule/); + assert.equal(implementationCalls, 1); +}); + +test('persistent PTY rules fail closed for cursor editing and oversized fragments', async () => { + const h = harness(); + let implementationCalls = 0; + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: ['git push *'], + denyPaths: [], + }), + permissionRuntimeState: createPermissionRuntimeState(), + }); + const tool: MakaTool = { + name: 'WriteStdin', + description: 'test', + parameters: z.object({ + ref: z.string(), + input: z.string().optional(), + actions: z.array(z.unknown()).optional(), + }), + impl: async () => { + implementationCalls += 1; + return { ok: true }; + }, + }; + const ref = 'maka://runtime/background-tasks/pty-edit'; + const edited = await settle( + h, + tool, + { ref, actions: [{ type: 'key', key: 'arrow_left' }] }, + { runtime, toolCallId: 'call_pty_cursor_edit' }, + ); + assert.match((edited.result as { error: string }).error, /persistent permission rule/); + + const oversized = await settle( + h, + tool, + { ref, input: 'x'.repeat(64 * 1024 + 1) }, + { runtime, toolCallId: 'call_pty_oversized' }, + ); + assert.match((oversized.result as { error: string }).error, /persistent permission rule/); + assert.equal(implementationCalls, 0); +}); + test('client-capability refusal carries actionable bypass metadata', async () => { const h = harness(); await settle(h, clientCapabilityTool(), {}, { toolCallId: 'call_boundary_metadata' }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 2a065fffb2..cbbbd65d48 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -33,6 +33,8 @@ import type { BackendSendInput, HostedInteractionBridge, } from '@maka/core/backend-types'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { PermissionRules } from '@maka/core/runtime-policy'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { EffectiveOrchestration } from '@maka/core/orchestration'; @@ -120,6 +122,10 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { readExecutionBoundary: ToolRuntimeInput['readExecutionBoundary']; createSandboxBoundaryRequest?: ToolRuntimeInput['createSandboxBoundaryRequest']; settleSandboxBoundaryRequest?: ToolRuntimeInput['settleSandboxBoundaryRequest']; + /** Host-owned persistent deny rules, captured for the backend generation. */ + permissionRules?: PermissionRules; + /** Session-scoped state retained when the Host rebuilds this backend. */ + permissionRuntimeState?: ToolRuntimeInput['permissionRuntimeState']; // ── Process-singleton deps ───────────────────────────────────────────── /** Canonical-named tools available this session. */ @@ -345,7 +351,10 @@ export class AiSdkBackend implements AgentBackend { beforeRunProviderDispatch: input.beforeRunProviderDispatch, }); const runtime = resolveModelRuntime(input.connection, input.modelId); - const applyPatchProfile = runtime.applyPatchProfile; + const applyPatchProfile = + input.permissionRules === undefined || input.permissionRules.denyPaths.length === 0 + ? runtime.applyPatchProfile + : null; this.messageProjection = new AiSdkMessageProjection({ modelAdapter: this.modelAdapter, applyPatchProfile, @@ -448,6 +457,8 @@ export class AiSdkBackend implements AgentBackend { readExecutionBoundary: input.readExecutionBoundary, createSandboxBoundaryRequest: input.createSandboxBoundaryRequest, settleSandboxBoundaryRequest: input.settleSandboxBoundaryRequest, + permissionRules: input.permissionRules, + permissionRuntimeState: input.permissionRuntimeState, newId: this.newId, now: this.now, getPermissionPauseTarget: () => identity.scope().watchdog, diff --git a/packages/runtime/src/apply-patch-profile.ts b/packages/runtime/src/apply-patch-profile.ts index d4d524b4ee..a27b8d7ea9 100644 --- a/packages/runtime/src/apply-patch-profile.ts +++ b/packages/runtime/src/apply-patch-profile.ts @@ -48,10 +48,14 @@ export function resolveApplyPatchProfile( export function routeApplyPatchTools( tools: readonly MakaTool[], profile: ApplyPatchProfile | null, + nativeApplyPatchAllowed = true, ): MakaTool[] { const applyPatchTool = tools.find((tool) => tool.providerTool?.kind === 'openai-apply-patch'); if (!applyPatchTool) return [...tools]; - if (!profile) return tools.filter((tool) => tool !== applyPatchTool); + // Native Apply Patch executes at the provider, so it cannot be guarded by + // ToolRuntime's persistent path rules. Fall back to the client-side file + // tools whenever a path deny is active; those tools settle through Runtime. + if (!profile || !nativeApplyPatchAllowed) return tools.filter((tool) => tool !== applyPatchTool); return tools.filter((tool) => tool.name !== 'Write' && tool.name !== 'Edit'); } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index d18cce8d65..f27f4a3c91 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -94,6 +94,7 @@ import type { } from './session-manager.js'; import type { TurnShellPlan } from './shell-detect.js'; import type { ShellRunProcessManager } from './shell-run-manager.js'; +import type { PermissionRuntimeState } from './tool-runtime.js'; import { buildStatusPatch, buildTurnStateMessage, @@ -271,6 +272,7 @@ export interface RuntimeKernelDeps { childTools?: readonly MakaTool[]; resolveChildTools?: (sessionId: string) => Promise; shellRuns?: ShellRunProcessManager; + permissionRuntimeState?: PermissionRuntimeState; cleanupHistoryCompactArtifacts?: (input: HistoryCompactCleanupRequest) => Promise; inspectContinuationSafety?: (sessionId: string) => Promise; safeBoundaryResumeEnabled?: boolean; @@ -2433,6 +2435,7 @@ export class RuntimeKernel implements RuntimeKernelLike { sessionId, }), allowMidTurnHistoryCompaction: Boolean(this.deps.runtimeEventStore), + permissionRuntimeState: this.deps.permissionRuntimeState, }); await this.rejectCancelledBackendActivation(backend, header, execution); const generation = this.createBackendGeneration( diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 91bd2d1021..bfe675c442 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -170,6 +170,7 @@ import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import { readLatestContextDiagnostics, type ContextDiagnostics } from './context-diagnostics.js'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { ShellRunProcessManager } from './shell-run-manager.js'; +import type { PermissionRuntimeState } from './tool-runtime.js'; import type { HistoryCompactCheckpoint } from './history-compact-checkpoint.js'; import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; import type { LoadedModelProjectionTransitions } from './model-projection-transition-ledger.js'; @@ -725,6 +726,9 @@ export interface BackendFactoryContext { loadTurnRuntimeEvents?: (turnId: string) => Promise; /** Whether this activation may fold its run ledger into session-scoped history. */ allowMidTurnHistoryCompaction?: boolean; + shellRunContextSummary?: () => Promise; + /** Session-scoped runtime state retained across backend generations. */ + permissionRuntimeState?: PermissionRuntimeState; } export type BackendFactory = (ctx: BackendFactoryContext) => AgentBackend | Promise; @@ -818,6 +822,7 @@ interface SessionManagerBaseDeps { /** Optional host-owned parent run authority for runtimes that execute the parent externally. */ isParentRunActive?: (sessionId: string, runId: string, turnId: string) => boolean; shellRuns?: ShellRunProcessManager; + permissionRuntimeState?: PermissionRuntimeState; cleanupHistoryCompactArtifacts?: (input: HistoryCompactCleanupRequest) => Promise; inspectContinuationSafety?: (sessionId: string) => Promise; continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise; @@ -909,7 +914,13 @@ export class SessionManager { now: deps.now, }); } - this.runtimeKernel = deps.runtimeKernel ?? new RuntimeKernel({ ...deps }); + const runtimeKernelDeps = { + ...deps, + permissionRuntimeState: deps.permissionRuntimeState ?? { + pendingPtyCommandInput: new Map(), + }, + }; + this.runtimeKernel = deps.runtimeKernel ?? new RuntimeKernel(runtimeKernelDeps); } // -------------------------------------------------------------------------- diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a12fdce73e..cc420fb0b6 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -18,6 +18,11 @@ */ import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; +import { + matchPermissionRules, + permissionPathWithinRoot, + type PermissionRules, +} from '@maka/core/runtime-policy'; import { projectAgentSwarmResult } from '@maka/core/agent-swarm'; import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; import { @@ -84,6 +89,7 @@ import { type RuntimeEventManagedWorkspaceMutationV2, } from '@maka/core/runtime-event'; import { isDeepStrictEqual } from 'node:util'; +import { isAbsolute, resolve } from 'node:path'; import { recordToolArtifactsSafely, type ToolArtifactRecorder } from './tool-artifacts.js'; import { computerActionFields, describeComputerUseArgsViolation } from './computer-use-codec.js'; @@ -136,6 +142,9 @@ import { type RuntimeInteractionClosureReason, type RuntimeUserQuestionClosureReason, } from './interaction-authority.js'; +import { realpathAllowMissing } from './path-containment.js'; + +const MAX_PENDING_PTY_COMMAND_INPUT_CHARS = 64 * 1024; export interface ResolvedMakaToolCall { tool: MakaTool; @@ -391,6 +400,10 @@ export interface ToolRuntimeInput { * still describe the turn that dispatched it (#1990). */ turnId: string; + /** Host-owned persistent deny rules. These outrank the session boundary mode. */ + permissionRules?: PermissionRules; + /** Session-scoped state for PTY command fragments across backend generations. */ + permissionRuntimeState?: PermissionRuntimeState; hostedInteraction?: HostedInteractionBridge; /** * Durable identity of the ONE run this ToolRuntime serves, fixed at @@ -604,10 +617,20 @@ export class ToolRuntime { string, { callCount: number; exclusiveToolName?: string } >(); + /** + * Command text typed into each model-owned PTY since its last line break. + * Keeping the unfinished line here closes the split-call form of a PTY + * command deny (`WriteStdin("git ")`, then `WriteStdin("push ...")`). + */ + private readonly pendingPtyCommandInput: Map; + private readonly ownsPendingPtyCommandInput: boolean; constructor(private readonly input: ToolRuntimeInput) { if (!input.readExecutionBoundary) { throw new Error('ToolRuntime requires explicit execution boundary authority'); } + this.ownsPendingPtyCommandInput = input.permissionRuntimeState === undefined; + this.pendingPtyCommandInput = + input.permissionRuntimeState?.pendingPtyCommandInput ?? new Map(); const hosted = input.hostedInteraction; if (hosted && (hosted.sessionId !== input.sessionId || hosted.turnId !== input.turnId)) { throw new RuntimeInteractionInvariantError( @@ -948,6 +971,7 @@ export class ToolRuntime { this.sandboxBoundaryFinalizationRequested = false; this.durableToolAttempts.clear(); this.stepAdmissions.clear(); + if (this.ownsPendingPtyCommandInput) this.pendingPtyCommandInput.clear(); } hasSandboxBoundaryDenial(): boolean { @@ -1427,6 +1451,40 @@ export class ToolRuntime { return this.errorReturn(msg); } + const persistentPermissionDenial = await this.findPersistentPermissionDenial( + tool, + executionArgs, + ); + if (persistentPermissionDenial !== undefined) { + await refuseBeforeDispatch(persistentPermissionDenial); + this.input.recordToolInvocation?.({ + sessionId: this.input.sessionId, + turnId, + toolCallId: toolUseId, + toolName: tool.name, + providerId: this.input.connection.providerType, + modelId: this.input.modelId, + durationMs: 0, + status: 'error', + errorClass: 'PersistentPermissionDenied', + argsSummary: + tool.categoryHint === 'computer_use' + ? summarizePersistedArgs(persistedArgs) + : summarizeArgs(tool.name, executionArgs), + bytesIn: byteLength(persistedArgs), + bytesOut: byteLength(persistentPermissionDenial), + startedAt: now, + }); + trace?.emit('tool', 'tool_failed', 'Tool denied by persistent permission rule', { + toolUseId, + toolName: tool.name, + status: 'error', + errorClass: 'PersistentPermissionDenied', + }); + this.recordLoopGateOutcome(callSignature, true); + return this.errorReturn(persistentPermissionDenial); + } + // Loop-gate (#92): block this call up front — before the guards and the real // impl — if this exact call (tool + canonical args) has already FAILED // back-to-back the last (THRESHOLD-1) times. Re-running an identical failing @@ -3264,6 +3322,354 @@ export class ToolRuntime { }, }); } + + private async findPersistentPermissionDenial( + tool: MakaTool, + args: unknown, + ): Promise { + const configuredRules = this.input.permissionRules; + if (!configuredRules) return undefined; + const rules = permissionRulesForCurrentHost(configuredRules); + + if (tool.name === 'Bash' && isRecord(args) && typeof args.command === 'string') { + const match = matchPermissionRules(rules, { command: args.command }); + if (match?.kind === 'command') { + return `Tool Bash was denied by a persistent permission rule matching ${JSON.stringify(match.pattern)}.`; + } + const pathDenial = await this.findPersistentPathDenial( + tool.name, + commandPathCandidates(args.command), + rules, + ); + if (pathDenial !== undefined) return pathDenial; + } + + if (tool.name === 'WriteStdin' && isRecord(args) && typeof args.ref === 'string') { + const input = terminalCommandInput(args); + if (input !== undefined) { + if (input.unverifiable) { + return `${tool.name} was denied by a persistent permission rule because terminal editing input could not be verified safely.`; + } + const stateKey = permissionPtyStateKey(this.input.sessionId, args.ref); + const prior = this.pendingPtyCommandInput.get(stateKey) ?? ''; + const inspection = inspectPtyCommandInput(prior, input.text, rules); + if (inspection.match !== undefined) { + return `Tool WriteStdin was denied by a persistent permission rule matching ${JSON.stringify(inspection.match)}.`; + } + const pathDenial = await this.findPersistentPathDenial( + tool.name, + commandPathCandidates(`${prior}${input.text}`), + rules, + ); + if (pathDenial !== undefined) return pathDenial; + if (inspection.pending.length > MAX_PENDING_PTY_COMMAND_INPUT_CHARS) { + return `${tool.name} was denied by a persistent permission rule because its unfinished command exceeded the verification limit.`; + } + if (inspection.pending.length > 0) { + this.pendingPtyCommandInput.set(stateKey, inspection.pending); + } else { + this.pendingPtyCommandInput.delete(stateKey); + } + } + } + + const paths = permissionPathsForTool(tool.name, args, this.input.header.cwd); + if (paths.length === 0) return undefined; + const pathDenial = await this.findPersistentPathDenial(tool.name, paths, rules); + if (pathDenial !== undefined) return pathDenial; + if (rules.denyPaths.length === 0) return undefined; + if (tool.name === 'Glob' || tool.name === 'Grep') { + const searchRoot = paths[0]; + if (searchRoot !== undefined) { + let canonicalRoot: string; + try { + canonicalRoot = await this.resolvePermissionPath(searchRoot); + } catch { + return `${tool.name} was denied by a persistent permission rule because its search scope could not be verified safely.`; + } + for (const rule of rules.denyPaths) { + if ( + permissionPathWithinRoot(canonicalRoot, rule.path) || + permissionPathWithinRoot(rule.path, canonicalRoot) + ) { + return `${tool.name} was denied by a persistent permission rule because its search scope includes ${rule.scope} path ${JSON.stringify(rule.path)}.`; + } + } + } + } + return undefined; + } + + private async findPersistentPathDenial( + toolName: string, + paths: readonly string[], + rules: PermissionRules, + ): Promise { + if (rules.denyPaths.length === 0) return undefined; + for (const path of paths) { + let canonicalPath: string; + try { + canonicalPath = await this.resolvePermissionPath(path); + } catch { + return `${toolName} was denied by a persistent permission rule because its path could not be verified safely.`; + } + const match = matchPermissionRules(rules, { path: canonicalPath }); + if (match?.kind === 'path') { + return `${toolName} was denied by a persistent permission rule for ${match.rule.scope} path ${JSON.stringify(match.rule.path)}.`; + } + } + return undefined; + } + + private async resolvePermissionPath(path: string): Promise { + const requested = isAbsolute(path) ? resolve(path) : resolve(this.input.header.cwd, path); + return await realpathAllowMissing(requested); + } +} + +function permissionPathsForTool(toolName: string, args: unknown, cwd: string): readonly string[] { + if (toolName === 'apply_patch' && typeof args === 'string') return applyPatchTextPaths(args); + if (!isRecord(args)) return []; + switch (toolName) { + case 'Bash': + return typeof args.command === 'string' ? commandPathCandidates(args.command) : []; + case 'Read': + case 'Write': + case 'Edit': + case 'FormatJson': + return typeof args.path === 'string' ? [args.path] : []; + case 'Grep': + return [typeof args.path === 'string' ? args.path : '.']; + case 'Glob': { + const base = typeof args.cwd === 'string' ? args.cwd : '.'; + const pattern = typeof args.pattern === 'string' ? args.pattern : '.'; + return [resolve(cwd, base, globPatternBase(pattern))]; + } + case 'apply_patch': + return collectPathFields(args); + default: + return []; + } +} + +function commandPathCandidates(command: string): readonly string[] { + const candidates = new Set(); + const tokens = shellWordTokens(command); + for (const token of tokens) { + if (looksLikePath(token.value) || token.pathHint) candidates.add(token.value); + } + // Shell substitutions and quoted language snippets can hide a path from the + // word tokenizer. Literal absolute paths are safe to inspect as an additional + // conservative pass; canonical path matching still prevents `/mnt-other` + // from matching a `/mnt` subtree rule. + for (const match of command.matchAll( + /(?:\/[^\s"'`;&|()<>]+|[A-Za-z]:\\[^\s"'`;&|()<>]+|\\\\[^\s"'`;&|()<>]+)/g, + )) { + if (match[0]) candidates.add(trimShellPathToken(match[0])); + } + return [...candidates].filter((candidate) => candidate.length > 0); +} + +interface ShellWordToken { + readonly value: string; + readonly pathHint: boolean; +} + +function shellWordTokens(command: string): readonly ShellWordToken[] { + const tokens: ShellWordToken[] = []; + let value = ''; + let quote: "'" | '"' | undefined; + let pathHint = false; + const flush = () => { + if (value.length > 0) tokens.push({ value: trimShellPathToken(value), pathHint }); + value = ''; + pathHint = false; + }; + for (let index = 0; index < command.length; index += 1) { + const character = command[index]!; + if (quote !== undefined) { + if (character === quote) quote = undefined; + else value += character; + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if (character === '\\' && index + 1 < command.length) { + value += command[++index]!; + continue; + } + if (/\s/.test(character)) { + flush(); + continue; + } + if (';|&()<>'.includes(character)) { + flush(); + if (character === '>' || character === '<') pathHint = true; + continue; + } + value += character; + } + flush(); + return tokens; +} + +function trimShellPathToken(value: string): string { + return value.replace(/^[`([{]+|[`),;:}]+$/g, ''); +} + +function looksLikePath(value: string): boolean { + return ( + value.startsWith('/') || + /^[A-Za-z]:\\/.test(value) || + value.startsWith('\\\\') || + value.startsWith('./') || + value.startsWith('../') || + value.includes('/') || + value.includes('\\') || + value.includes('.') + ); +} + +function terminalCommandInput( + args: Record, +): { readonly text: string; readonly unverifiable: boolean } | undefined { + if (typeof args.input === 'string') { + return { text: args.input, unverifiable: hasUnmodelledTerminalControl(args.input) }; + } + if (!Array.isArray(args.actions)) return undefined; + let text = ''; + let unverifiable = false; + for (const action of args.actions) { + if (!isRecord(action) || typeof action.type !== 'string') { + unverifiable = true; + continue; + } + if (action.type === 'text' && typeof action.text === 'string') { + text += action.text; + } else if (action.type === 'key' && typeof action.key === 'string') { + if (action.key === 'enter') text += '\n'; + else if (action.key === 'backspace') text += '\b'; + else if ( + action.key.length === 1 && + (!Array.isArray(action.modifiers) || action.modifiers.length === 0) + ) { + text += action.key; + } else { + // Cursor movement, deletion at the cursor, and modifier chords can + // rewrite an earlier part of the line. A string concatenation model + // cannot prove the resulting command or path, so deny conservatively. + unverifiable = true; + } + } else { + // Mouse input and resize do not describe a verifiable command line. + unverifiable = true; + } + } + return text.length > 0 || unverifiable ? { text, unverifiable } : undefined; +} + +function hasUnmodelledTerminalControl(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 0x20 && character !== '\n' && character !== '\r' && character !== '\b'; + }); +} + +function inspectPtyCommandInput( + prior: string, + input: string, + rules: PermissionRules, +): { readonly match?: string; readonly pending: string } { + let pending = prior; + for (const character of input) { + if (character === '\n' || character === '\r' || ';|&'.includes(character)) { + const match = matchPermissionCommandFragments(rules, pending); + if (match !== undefined) return { match, pending }; + pending = ''; + continue; + } + if (character === '\b' || character === '\u007f') { + pending = [...pending].slice(0, -1).join(''); + continue; + } + pending += character; + const match = matchPermissionCommandFragments(rules, pending); + if (match !== undefined) return { match, pending }; + } + return { pending }; +} + +function matchPermissionCommandFragments( + rules: PermissionRules, + input: string, +): string | undefined { + const value = input.trim(); + if (value.length === 0) return undefined; + const match = matchPermissionRules(rules, { command: value }); + return match?.kind === 'command' ? match.pattern : undefined; +} + +function globPatternBase(pattern: string): string { + const wildcard = pattern.search(/[?*[{]/); + if (wildcard < 0) return pattern || '.'; + const prefix = pattern.slice(0, wildcard); + const separator = Math.max(prefix.lastIndexOf('/'), prefix.lastIndexOf('\\')); + return separator < 0 ? '.' : prefix.slice(0, separator) || (isAbsolute(pattern) ? '/' : '.'); +} + +function applyPatchTextPaths(input: string): readonly string[] { + const paths: string[] = []; + for (const line of input.replaceAll('\r\n', '\n').split('\n')) { + const match = /^\*\*\* (?:Add|Delete|Update) File: (.+)$/.exec(line); + if (match?.[1]) paths.push(match[1].trim()); + } + return paths; +} + +function collectPathFields(value: unknown): readonly string[] { + if (Array.isArray(value)) return value.flatMap((item) => collectPathFields(item)); + if (!isRecord(value)) return []; + const paths: string[] = []; + if (typeof value.path === 'string') paths.push(value.path); + for (const [key, nested] of Object.entries(value)) { + if (key !== 'path') paths.push(...collectPathFields(nested)); + } + return paths; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export interface PermissionRuntimeState { + readonly pendingPtyCommandInput: Map; +} + +export function createPermissionRuntimeState(): PermissionRuntimeState { + return { pendingPtyCommandInput: new Map() }; +} + +function permissionPtyStateKey(sessionId: string, ref: string): string { + return `${sessionId}\u0000${ref}`; +} + +/** + * Permission policy is persisted in a platform-neutral document, while the + * filesystem executor resolves paths using the current Host's path API. On + * Windows, Node treats a POSIX-looking absolute path such as `/mnt` as the + * root of the current drive (`D:\\mnt`); convert that spelling before matching + * so a rule configured through the CLI cannot silently become ineffective. + */ +function permissionRulesForCurrentHost(rules: PermissionRules): PermissionRules { + if (process.platform !== 'win32') return rules; + return { + denyCommands: rules.denyCommands, + denyPaths: rules.denyPaths.map((rule) => + rule.path.startsWith('/') ? { ...rule, path: resolve(rule.path) } : rule, + ), + }; } async function validateDeclaredToolArgs(parameters: unknown, args: unknown): Promise { diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index dbc20f4ac0..6452b96612 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -65,7 +65,11 @@ const execFileAsync = promisify(execFile); describe('runtime policy stores', () => { test('upgrades schema v2 with the automatic Host shell default', async () => { await withInteractiveOwner(async ({ root, stores }) => { - const { shell: _shell, ...policyV2 } = createDefaultRuntimePolicy(); + const { + shell: _shell, + permissionRules: _permissionRules, + ...policyV2 + } = createDefaultRuntimePolicy(); await writeFile( join(root, 'runtime-policy.json'), `${JSON.stringify({ schemaVersion: 2, revision: 4, policy: policyV2 })}\n`, @@ -82,7 +86,41 @@ describe('runtime policy stores', () => { const persisted = JSON.parse(await readFile(join(root, 'runtime-policy.json'), 'utf8')) as { schemaVersion: number; }; - assert.equal(persisted.schemaVersion, 3); + assert.equal(persisted.schemaVersion, 4); + }); + }); + + test('upgrades schema v3 with empty persistent permission rules', async () => { + await withInteractiveOwner(async ({ root, stores }) => { + const { permissionRules: _permissionRules, ...policyV3 } = createDefaultRuntimePolicy(); + await writeFile( + join(root, 'runtime-policy.json'), + `${JSON.stringify({ schemaVersion: 3, revision: 7, policy: policyV3 })}\n`, + ); + + const snapshot = await stores.runtimePolicy.getSnapshot(); + assert.equal(snapshot.revision, 7); + assert.deepEqual(snapshot.policy.permissionRules, { denyCommands: [], denyPaths: [] }); + const committed = await stores.runtimePolicy.mutate({ + expectedRevision: 7, + operation: { + kind: 'set_permission_rules', + value: { + denyCommands: ['git push *'], + denyPaths: [{ path: '/etc/wsl.conf', scope: 'exact' }], + }, + }, + }); + assert.equal(committed.kind, 'committed'); + const persisted = JSON.parse(await readFile(join(root, 'runtime-policy.json'), 'utf8')) as { + schemaVersion: number; + policy: { permissionRules: unknown }; + }; + assert.equal(persisted.schemaVersion, 4); + assert.deepEqual(persisted.policy.permissionRules, { + denyCommands: ['git push *'], + denyPaths: [{ path: '/etc/wsl.conf', scope: 'exact' }], + }); }); }); diff --git a/packages/storage/src/runtime-policy/policy-document.ts b/packages/storage/src/runtime-policy/policy-document.ts index 02c450382a..6e2e0ba168 100644 --- a/packages/storage/src/runtime-policy/policy-document.ts +++ b/packages/storage/src/runtime-policy/policy-document.ts @@ -21,6 +21,7 @@ import { createDefaultRuntimePolicy, decodeCanonicalRuntimePolicy, decodeRuntimePolicyV2, + decodeRuntimePolicyV3, normalizeRuntimePolicyMutation, type MutateRuntimePolicyInput, type MutateRuntimePolicyResult, @@ -43,7 +44,7 @@ import { } from './document-io.js'; const FILE = 'runtime-policy.json'; -const SCHEMA_VERSION = 3 as const; +const SCHEMA_VERSION = 4 as const; export interface RuntimePolicyDocument { readonly schemaVersion: typeof SCHEMA_VERSION; @@ -68,7 +69,11 @@ export class RuntimePolicyDocumentOwner { 'revision', 'policy', ]); - if (document.schemaVersion !== 2 && document.schemaVersion !== SCHEMA_VERSION) { + if ( + document.schemaVersion !== 2 && + document.schemaVersion !== 3 && + document.schemaVersion !== SCHEMA_VERSION + ) { throw codecError('invalid_document', `${FILE} has an unsupported schema version`); } return { @@ -77,7 +82,9 @@ export class RuntimePolicyDocumentOwner { policy: decodePersistedDomain(() => document.schemaVersion === 2 ? decodeRuntimePolicyV2(document.policy) - : decodeCanonicalRuntimePolicy(document.policy), + : document.schemaVersion === 3 + ? decodeRuntimePolicyV3(document.policy) + : decodeCanonicalRuntimePolicy(document.policy), ), }; } @@ -153,6 +160,8 @@ function applyMutation(policy: RuntimePolicy, operation: RuntimePolicyMutation): return { ...policy, subagents: operation.value }; case 'set_shell': return { ...policy, shell: operation.value }; + case 'set_permission_rules': + return { ...policy, permissionRules: operation.value }; case 'patch_agent_settings': return { ...policy, From 2053d585b5ceb13eebd54d7bc5b85430286e5b31 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 5 Sep 2026 22:50:05 +0800 Subject: [PATCH 2/7] fix(runtime): apply persistent permission rules to live backends Generated-by: Codex --- .../__tests__/runtime-policy-codec.test.ts | 14 ++++ packages/core/src/runtime-policy.ts | 2 + .../src/runtime-policy/permission-rules.ts | 76 ++++++++++++++----- .../src/server/execution-composition.ts | 10 +++ .../src/server/execution-model-composition.ts | 5 ++ .../pre-dispatch-refusal-ledger.test.ts | 38 ++++++++++ packages/runtime/src/ai-sdk-backend.ts | 5 +- packages/runtime/src/tool-runtime.ts | 27 +++++-- 8 files changed, 149 insertions(+), 28 deletions(-) diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index a13d062fc3..71933a4cd8 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -24,6 +24,7 @@ import { decodeCanonicalConnectionCatalogEntry, decodeCanonicalRuntimePolicy, decodeCanonicalPermissionRules, + compilePermissionRules, matchPermissionRules, normalizePermissionRules, decodeRelayModelProfilesTable, @@ -60,6 +61,19 @@ test('normalizes and matches persistent deny rules without treating patterns as assert.equal(matchPermissionRules(rules, { path: '/etc/wsl.conf.d/extra' }), undefined); }); +test('reuses the compiled matcher for one immutable permission-rule snapshot', () => { + const rules = normalizePermissionRules({ + denyCommands: ['git push *'], + denyPaths: [{ path: '/mnt', scope: 'subtree' }], + }); + const first = compilePermissionRules(rules); + const second = compilePermissionRules(rules); + + assert.strictEqual(second, first); + assert.equal(first.match({ command: 'git push origin main' })?.kind, 'command'); + assert.equal(first.match({ path: '/mnt/worktree/file.txt' })?.kind, 'path'); +}); + test('normalizes permission-rule mutations and rejects unsafe path rules', () => { const mutation = normalizeRuntimePolicyMutation({ expectedRevision: 0, diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index 34f9cf215d..acb459fe36 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -60,6 +60,7 @@ export { } from './runtime-policy/policy-codec.js'; export { decodeCanonicalPermissionRules, + compilePermissionRules, EMPTY_PERMISSION_RULES, matchPermissionRules, normalizePermissionRules, @@ -72,6 +73,7 @@ export { } from './runtime-policy/permission-rules.js'; export type { PermissionPathRule, + CompiledPermissionRules, PermissionRuleMatch, PermissionRules, } from './runtime-policy/permission-rules.js'; diff --git a/packages/core/src/runtime-policy/permission-rules.ts b/packages/core/src/runtime-policy/permission-rules.ts index 5d983e4abd..46e9a90136 100644 --- a/packages/core/src/runtime-policy/permission-rules.ts +++ b/packages/core/src/runtime-policy/permission-rules.ts @@ -45,11 +45,23 @@ export type PermissionRuleMatch = | { readonly kind: 'command'; readonly pattern: string } | { readonly kind: 'path'; readonly rule: PermissionPathRule }; +export interface CompiledPermissionRules { + readonly rules: PermissionRules; + match(request: PermissionRuleRequest): PermissionRuleMatch | undefined; +} + +interface PermissionRuleRequest { + readonly command?: string; + readonly path?: string; +} + export const EMPTY_PERMISSION_RULES: PermissionRules = Object.freeze({ denyCommands: Object.freeze([]), denyPaths: Object.freeze([]), }); +const compiledRulesCache = new WeakMap(); + export function normalizePermissionRules(value: unknown): PermissionRules { const item = exactRecord(value, 'permission rules', ['denyCommands', 'denyPaths']); if ( @@ -79,7 +91,10 @@ export function normalizePermissionRules(value: unknown): PermissionRules { ) .sort(comparePathRules); - return { denyCommands, denyPaths }; + return Object.freeze({ + denyCommands: Object.freeze(denyCommands), + denyPaths: Object.freeze(denyPaths.map((rule) => Object.freeze(rule))), + }); } export function decodeCanonicalPermissionRules(value: unknown): PermissionRules { @@ -90,25 +105,46 @@ export function decodeCanonicalPermissionRules(value: unknown): PermissionRules export function matchPermissionRules( rules: PermissionRules, - request: { readonly command?: string; readonly path?: string }, + request: PermissionRuleRequest, ): PermissionRuleMatch | undefined { - if (request.command !== undefined) { - for (const pattern of rules.denyCommands) { - if (globMatches(pattern, request.command)) return { kind: 'command', pattern }; - } - } - if (request.path !== undefined) { - for (const rule of rules.denyPaths) { - if ( - rule.scope === 'exact' - ? samePath(rule.path, request.path) - : pathWithinRoot(request.path, rule.path) - ) { - return { kind: 'path', rule }; + return compilePermissionRules(rules).match(request); +} + +/** Compile one immutable rule set once and reuse it for subsequent matches. */ +export function compilePermissionRules(rules: PermissionRules): CompiledPermissionRules { + const cached = compiledRulesCache.get(rules); + if (cached) return cached; + + const commandMatchers = rules.denyCommands.map((pattern) => ({ + pattern, + matcher: compileGlob(pattern), + })); + const compiled: CompiledPermissionRules = Object.freeze({ + rules, + match(request: PermissionRuleRequest): PermissionRuleMatch | undefined { + if (request.command !== undefined) { + for (const entry of commandMatchers) { + if (entry.matcher.test(request.command)) { + return { kind: 'command', pattern: entry.pattern }; + } + } } - } - } - return undefined; + if (request.path !== undefined) { + for (const rule of rules.denyPaths) { + if ( + rule.scope === 'exact' + ? samePath(rule.path, request.path) + : pathWithinRoot(request.path, rule.path) + ) { + return { kind: 'path', rule }; + } + } + } + return undefined; + }, + }); + compiledRulesCache.set(rules, compiled); + return compiled; } /** Compare canonical permission paths using the platform-aware path rules. */ @@ -159,7 +195,7 @@ function comparePathRules(left: PermissionPathRule, right: PermissionPathRule): ); } -function globMatches(pattern: string, value: string): boolean { +function compileGlob(pattern: string): RegExp { let source = '^'; for (let index = 0; index < pattern.length; index += 1) { const character = pattern[index]!; @@ -183,7 +219,7 @@ function globMatches(pattern: string, value: string): boolean { } } source += '$'; - return new RegExp(source).test(value); + return new RegExp(source); } function escapeRegExp(value: string): string { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 2e38c38f8d..0b73a5b41b 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -26,6 +26,7 @@ import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; import { emptyPlanSessionState } from '@maka/core/plan'; import type { PermissionMode } from '@maka/core/permission'; +import { EMPTY_PERMISSION_RULES, type PermissionRules } from '@maka/core/runtime-policy'; import { runtimeInvocationOutcome, type RuntimeInvocationRecord, @@ -268,6 +269,11 @@ export async function createExecutionRuntimeHostComposition( } let stopRetiredCaptureSweep: (() => void) | undefined; const stores = storage.execution; + let currentPermissionRules: PermissionRules = EMPTY_PERMISSION_RULES; + const readPermissionRules = (): PermissionRules => currentPermissionRules; + const updatePermissionRules = (rules: PermissionRules): void => { + currentPermissionRules = rules; + }; let graphControlStore: ReturnType | undefined; let graphClient: HostAgentGraphCoordinator | undefined; let sessionEffects: HostSessionEffectCoordinator | undefined; @@ -770,6 +776,8 @@ export async function createExecutionRuntimeHostComposition( ), runtimeCommitSink: stores.runtimeEventStore, requestDrain: context.requestDrain, + readPermissionRules, + updatePermissionRules, }); backends.register( 'ai-sdk', @@ -1340,6 +1348,8 @@ export async function createExecutionRuntimeHostComposition( }); async function applyRuntimePolicyMutationEffects(): Promise { try { + const snapshot = await runtimePolicyStores.runtimePolicy.getSnapshot(); + updatePermissionRules(snapshot.policy.permissionRules); await requireMemory(memory).refreshAfterPolicyMutation(); } catch (error) { context.requestDrain(); diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 559d561a08..5705ee2a9d 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -24,6 +24,7 @@ import { relayModelProfile } from '@maka/core/model-thinking'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { PermissionMode } from '@maka/core/permission'; +import type { PermissionRules } from '@maka/core/runtime-policy'; import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; import { buildDefaultContextBudgetPolicy, @@ -85,6 +86,8 @@ export interface HostAiSdkBackendInput { readonly executionArtifacts: HostExecutionArtifactServices; readonly usage: HostExecutionUsageAuthority; readonly requestDrain: () => void; + readonly readPermissionRules?: () => PermissionRules; + readonly updatePermissionRules?: (rules: PermissionRules) => void; readonly runtimeCommitSink?: RuntimeCommitSink; readonly childAgents?: HostChildAgentBackendCapabilities; readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; @@ -143,6 +146,7 @@ async function buildHostAiSdkBackend( () => input.runtimePolicy.runtimePolicy.getSnapshot(), input.context.abortSignal, ); + input.updatePermissionRules?.(runtimePolicySnapshot.policy.permissionRules); const transport = createFetchTransport( toRuntimePolicyProxy(target.networkProxy, target.proxySecret), ); @@ -365,6 +369,7 @@ async function buildHostAiSdkBackend( } : {}), permissionRules: runtimePolicySnapshot.policy.permissionRules, + readPermissionRules: input.readPermissionRules, permissionRuntimeState: input.context.permissionRuntimeState, connection: target.connection, providerStateIdentity: target.providerStateIdentity, diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index f4a0a89b83..a48db6aa5f 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -446,6 +446,44 @@ test('persistent path denies cover literal paths used by Bash', async () => { } }); +test('an existing ToolRuntime reads updated persistent rules before the next dispatch', async () => { + const h = harness(); + let rules = normalizePermissionRules({ denyCommands: [], denyPaths: [] }); + let executions = 0; + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + readPermissionRules: () => rules, + }); + const tool: MakaTool = { + name: 'Bash', + description: 'test', + parameters: z.object({ command: z.string() }), + impl: async () => { + executions += 1; + return { ok: true }; + }, + }; + + const allowed = await settle( + h, + tool, + { command: 'git push origin main' }, + { runtime, toolCallId: 'call_live_rules_allowed' }, + ); + assert.deepEqual((allowed.result as { ok: boolean }).ok, true); + + rules = normalizePermissionRules({ denyCommands: ['git push *'], denyPaths: [] }); + const denied = await settle( + h, + tool, + { command: 'git push origin main' }, + { runtime, toolCallId: 'call_live_rules_denied' }, + ); + assert.match((denied.result as { error: string }).error, /persistent permission rule/); + assert.equal(executions, 1); + assert.deepEqual(ledgerIssues(h), []); +}); + test('persistent path denies cover recursive Glob and Grep search scopes', async () => { const cases: Array<{ tool: MakaTool; input: unknown }> = [ { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index cbbbd65d48..c0992edc3f 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -122,8 +122,10 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { readExecutionBoundary: ToolRuntimeInput['readExecutionBoundary']; createSandboxBoundaryRequest?: ToolRuntimeInput['createSandboxBoundaryRequest']; settleSandboxBoundaryRequest?: ToolRuntimeInput['settleSandboxBoundaryRequest']; - /** Host-owned persistent deny rules, captured for the backend generation. */ + /** Host-owned persistent deny rules used when no live provider is supplied. */ permissionRules?: PermissionRules; + /** Reads the current Host-owned deny rules before each local tool dispatch. */ + readPermissionRules?: ToolRuntimeInput['readPermissionRules']; /** Session-scoped state retained when the Host rebuilds this backend. */ permissionRuntimeState?: ToolRuntimeInput['permissionRuntimeState']; @@ -458,6 +460,7 @@ export class AiSdkBackend implements AgentBackend { createSandboxBoundaryRequest: input.createSandboxBoundaryRequest, settleSandboxBoundaryRequest: input.settleSandboxBoundaryRequest, permissionRules: input.permissionRules, + readPermissionRules: input.readPermissionRules, permissionRuntimeState: input.permissionRuntimeState, newId: this.newId, now: this.now, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index cc420fb0b6..1bfc86668e 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -19,6 +19,7 @@ import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { + compilePermissionRules, matchPermissionRules, permissionPathWithinRoot, type PermissionRules, @@ -402,6 +403,8 @@ export interface ToolRuntimeInput { turnId: string; /** Host-owned persistent deny rules. These outrank the session boundary mode. */ permissionRules?: PermissionRules; + /** Reads the current Host-owned deny rules for each pre-dispatch check. */ + readPermissionRules?: () => PermissionRules; /** Session-scoped state for PTY command fragments across backend generations. */ permissionRuntimeState?: PermissionRuntimeState; hostedInteraction?: HostedInteractionBridge; @@ -3327,12 +3330,13 @@ export class ToolRuntime { tool: MakaTool, args: unknown, ): Promise { - const configuredRules = this.input.permissionRules; + const configuredRules = this.input.readPermissionRules?.() ?? this.input.permissionRules; if (!configuredRules) return undefined; const rules = permissionRulesForCurrentHost(configuredRules); + const matcher = compilePermissionRules(rules); if (tool.name === 'Bash' && isRecord(args) && typeof args.command === 'string') { - const match = matchPermissionRules(rules, { command: args.command }); + const match = matcher.match({ command: args.command }); if (match?.kind === 'command') { return `Tool Bash was denied by a persistent permission rule matching ${JSON.stringify(match.pattern)}.`; } @@ -3406,6 +3410,7 @@ export class ToolRuntime { rules: PermissionRules, ): Promise { if (rules.denyPaths.length === 0) return undefined; + const matcher = compilePermissionRules(rules); for (const path of paths) { let canonicalPath: string; try { @@ -3413,7 +3418,7 @@ export class ToolRuntime { } catch { return `${toolName} was denied by a persistent permission rule because its path could not be verified safely.`; } - const match = matchPermissionRules(rules, { path: canonicalPath }); + const match = matcher.match({ path: canonicalPath }); if (match?.kind === 'path') { return `${toolName} was denied by a persistent permission rule for ${match.rule.scope} path ${JSON.stringify(match.rule.path)}.`; } @@ -3664,14 +3669,22 @@ function permissionPtyStateKey(sessionId: string, ref: string): string { */ function permissionRulesForCurrentHost(rules: PermissionRules): PermissionRules { if (process.platform !== 'win32') return rules; - return { + const cached = hostPermissionRulesCache.get(rules); + if (cached) return cached; + const converted = Object.freeze({ denyCommands: rules.denyCommands, - denyPaths: rules.denyPaths.map((rule) => - rule.path.startsWith('/') ? { ...rule, path: resolve(rule.path) } : rule, + denyPaths: Object.freeze( + rules.denyPaths.map((rule) => + Object.freeze(rule.path.startsWith('/') ? { ...rule, path: resolve(rule.path) } : rule), + ), ), - }; + }); + hostPermissionRulesCache.set(rules, converted); + return converted; } +const hostPermissionRulesCache = new WeakMap(); + async function validateDeclaredToolArgs(parameters: unknown, args: unknown): Promise { if (!parameters || (typeof parameters !== 'object' && typeof parameters !== 'function')) { return; From f650c635bbd91f4c6d758e9cff5210d80d4c2d48 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 5 Sep 2026 23:24:53 +0800 Subject: [PATCH 3/7] fix(ci): use canonical ASF header wrapping --- packages/core/src/runtime-policy/permission-rules.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime-policy/permission-rules.ts b/packages/core/src/runtime-policy/permission-rules.ts index 46e9a90136..7f77ae8981 100644 --- a/packages/core/src/runtime-policy/permission-rules.ts +++ b/packages/core/src/runtime-policy/permission-rules.ts @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { From 67fae3a1984f8a4ed57b160bdcf7f2bd2022195d Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 5 Sep 2026 23:45:45 +0800 Subject: [PATCH 4/7] fix(ci): cover Windows runtime policy changes --- .github/workflows/windows-recovery.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index aa7598430b..321cf96043 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -111,6 +111,7 @@ on: - 'packages/runtime/src/sandbox/windows-sandbox.ts' - 'packages/runtime/src/shell-detect.ts' - 'packages/runtime/src/shell-exec.ts' + - 'packages/runtime/src/tool-runtime.ts' - 'packages/storage/src/__tests__/managed-dependency-environment.test.ts' - 'packages/storage/src/__tests__/root-authority.test.ts' - 'packages/storage/src/artifact-store.ts' From 93d8d224be01ba10326087021163ce72ccb0b790 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 6 Sep 2026 00:36:35 +0800 Subject: [PATCH 5/7] fix(core): recognize POSIX root in path containment --- packages/core/src/__tests__/absolute-path.test.ts | 13 ++++++++++++- packages/core/src/absolute-path.ts | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/absolute-path.test.ts b/packages/core/src/__tests__/absolute-path.test.ts index b703881902..b5924c9a53 100644 --- a/packages/core/src/__tests__/absolute-path.test.ts +++ b/packages/core/src/__tests__/absolute-path.test.ts @@ -20,7 +20,12 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { pathWithinRoot, samePath, trimTrailingPathSeparators } from '../absolute-path.js'; +import { + isNormalizedAbsolutePath, + pathWithinRoot, + samePath, + trimTrailingPathSeparators, +} from '../absolute-path.js'; import { canonicalWindowsPath } from '../windows-path.js'; describe('absolute path comparison', () => { @@ -30,6 +35,12 @@ describe('absolute path comparison', () => { assert.equal(samePath('C:\\Workspace\\Project', 'c:\\workspace\\project'), true); assert.equal(samePath('/Workspace/project', '/workspace/project'), false); }); + + it('treats the POSIX root as a normalized root for containment checks', () => { + assert.equal(isNormalizedAbsolutePath('/'), true); + assert.equal(pathWithinRoot('/mnt', '/'), true); + assert.equal(pathWithinRoot('/', '/mnt'), false); + }); }); describe('trimTrailingPathSeparators', () => { diff --git a/packages/core/src/absolute-path.ts b/packages/core/src/absolute-path.ts index 7dcbe65156..d2c5d63d4c 100644 --- a/packages/core/src/absolute-path.ts +++ b/packages/core/src/absolute-path.ts @@ -36,6 +36,7 @@ export function isNormalizedAbsolutePath(path: string): boolean { .some((segment) => segment === '' || segment === '.' || segment === '..'); } if (!path.startsWith('/') || path.includes('\\')) return false; + if (path === '/') return true; if (path.length > 1 && path.endsWith('/')) return false; return !path .split('/') From 00483ab88cb3289b0ee78460ef31f103fa3088f7 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 6 Sep 2026 14:25:30 +0800 Subject: [PATCH 6/7] fix(runtime): align live permission enforcement --- packages/cli/README.md | 9 +- packages/cli/README.zh-CN.md | 11 +- .../__tests__/runtime-policy-codec.test.ts | 24 ++++ .../src/runtime-policy/permission-rules.ts | 20 +-- .../src/__tests__/ai-sdk-backend.test.ts | 98 +++++++++++++- .../pre-dispatch-refusal-ledger.test.ts | 123 +++++++++++++++++- packages/runtime/src/ai-sdk-backend.ts | 20 +-- .../runtime/src/ai-sdk-message-projection.ts | 9 +- packages/runtime/src/ai-sdk-turn.ts | 82 +++++++++--- packages/runtime/src/apply-patch-profile.ts | 6 + packages/runtime/src/tool-runtime.ts | 113 +++++++++++++--- 11 files changed, 448 insertions(+), 67 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index b93878908f..75a15639f5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -115,7 +115,14 @@ maka permissions list ``` Use `remove-command` or `remove-path` with the same value and scope to remove a rule. Paths must be -absolute. Command patterns use glob matching (`*` and `?`), not regular expressions. Unmatched +absolute. Command patterns use fragment-level glob matching (`*` and `?`), not regular expressions; +character classes are not supported, and Unicode command text is allowed. Bash and PTY input are +split conservatively at newlines and `;|&`; this is not a full shell parser, so expansion and command +substitution are outside the rule's guarantee. Filesystem rules are canonicalized using the Runtime +Host's actual filesystem semantics, including macOS case sensitivity and symlinks. A native +provider-side `apply_patch` is hidden whenever path denies are active, and an existing backend +refreshes that routing before the next provider request. Arbitrary MCP tool arguments are not +treated as filesystem paths; MCP servers must enforce their own path permissions. Unmatched operations continue to use the Session permission mode and sandbox. To manage a different local or remote Runtime Host, pass `--root ` and, where applicable, `--host `. diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index e9e5c55975..f587aa2c91 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -108,9 +108,14 @@ maka permissions list ``` 删除规则时,使用相同值和 scope 的 `remove-command` 或 `remove-path`。路径必须是绝对路径; -命令模式使用 glob 匹配(`*` 和 `?`),不是正则表达式。没有匹配规则的操作继续遵循当前 -Session 的 permission mode 和 sandbox。要管理其他本地或远程 Runtime Host,可传入 -`--root `,并在需要时传入 `--host `。 +命令模式使用分片级 glob 匹配(`*` 和 `?`),不是正则表达式;不支持字符类,但支持 Unicode +命令文本。Bash 和 PTY 输入会在换行及 `;|&` 处分片;这不是完整的 shell parser,因此展开和 +命令替换不在规则保证范围内。文件系统规则会使用 Runtime Host 实际文件系统的语义进行 +canonicalize,包括 macOS 的大小写敏感性和符号链接。存在路径拒绝规则时,provider-side 的 +原生 `apply_patch` 会隐藏;已有 backend 会在下一次 provider 请求前刷新该路由。MCP 任意工具 +参数不会被当作文件系统路径提取,MCP server 需要自行执行路径权限控制。没有匹配规则的操作 +继续遵循当前 Session 的 permission mode 和 sandbox。要管理其他本地或远程 Runtime Host, +可传入 `--root `,并在需要时传入 `--host `。 ## 升级 diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index 71933a4cd8..0457fc866f 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -74,6 +74,30 @@ test('reuses the compiled matcher for one immutable permission-rule snapshot', ( assert.equal(first.match({ path: '/mnt/worktree/file.txt' })?.kind, 'path'); }); +test('accepts Unicode command patterns and rejects unsupported character classes', () => { + const rules = normalizePermissionRules({ + denyCommands: ['运行脚本 *'], + denyPaths: [], + }); + assert.equal(matchPermissionRules(rules, { command: '运行脚本 部署' })?.kind, 'command'); + assert.throws( + () => + normalizePermissionRules({ + denyCommands: ['git push [a-z]'], + denyPaths: [], + }), + /only supports \* and \?/i, + ); + assert.throws( + () => + normalizePermissionRules({ + denyCommands: ['git\u0007push'], + denyPaths: [], + }), + /control characters/i, + ); +}); + test('normalizes permission-rule mutations and rejects unsafe path rules', () => { const mutation = normalizeRuntimePolicyMutation({ expectedRevision: 0, diff --git a/packages/core/src/runtime-policy/permission-rules.ts b/packages/core/src/runtime-policy/permission-rules.ts index 7f77ae8981..23826169d0 100644 --- a/packages/core/src/runtime-policy/permission-rules.ts +++ b/packages/core/src/runtime-policy/permission-rules.ts @@ -162,9 +162,14 @@ function normalizeCommandPattern(value: unknown, index: number): string { if (pattern.length === 0) { throw domainError(`permission rules denyCommands[${index}] must not be empty`); } - if (/[^\x20-\x7e]/.test(pattern)) { + if (/[\u0000-\u001f\u007f-\u009f]/.test(pattern)) { throw domainError( - `permission rules denyCommands[${index}] must contain printable characters only`, + `permission rules denyCommands[${index}] must not contain control characters`, + ); + } + if (pattern.includes('[')) { + throw domainError( + `permission rules denyCommands[${index}] only supports * and ? glob wildcards`, ); } return pattern; @@ -204,17 +209,6 @@ function compileGlob(pattern: string): RegExp { source += '[\\s\\S]*'; } else if (character === '?') { source += '[\\s\\S]'; - } else if (character === '[') { - const close = pattern.indexOf(']', index + 1); - if (close > index + 1) { - const body = pattern.slice(index + 1, close); - if (/^[^\\\]]+$/.test(body)) { - source += `[${body.replace(/[-^]/g, '\\$&')}]`; - index = close; - continue; - } - } - source += '\\['; } else { source += escapeRegExp(character); } diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 8d95be63c6..60196de0ff 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -32,6 +32,7 @@ import type { AttachmentByteReader } from '@maka/core/attachments'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { LlmConnection } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import type { PermissionRules } from '@maka/core/runtime-policy'; import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import type { SessionHeader } from '@maka/core/session'; import type { StorageRef } from '@maka/core/events'; @@ -196,6 +197,93 @@ describe('AiSdkBackend ApplyPatch routing', () => { assert.equal(names.includes('Edit'), true); }); + test('refreshes native apply_patch routing when live path rules change', async () => { + const durable = durableTurnHarness('turn-live-apply-patch', 'edit'); + let rules: PermissionRules = { + denyCommands: [], + denyPaths: [], + }; + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + if (streamCalls === 1) { + // The first request was already shaped under the empty snapshot. + // The next provider step must observe this update. + rules = { + denyCommands: [], + denyPaths: [{ path: '/blocked', scope: 'subtree' as const }], + }; + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'read-live-rules', + toolName: 'Read', + input: JSON.stringify({ path: 'notes.md' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { ...connection(), slug: 'openai', providerType: 'openai' }, + apiKey: 'sk-test', + modelId: 'gpt-5.4', + modelFactory: () => model, + tools: [ + nativeApplyPatchTool(), + testTool('Write', z.object({})), + testTool('Edit', z.object({})), + testTool('Read', z.object({ path: z.string() })), + ], + readPermissionRules: () => rules, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, + newId: idGenerator(), + now: monotonicClock(), + }); + + await drainDurably(backend.send(durable.input()), durable); + + assert.equal(streamCalls, 2); + const firstNames = modelToolNamesAt(model, 0); + const secondNames = modelToolNamesAt(model, 1); + assert.equal(firstNames.includes('apply_patch'), true); + assert.equal(firstNames.includes('Write'), false); + assert.equal(firstNames.includes('Edit'), false); + assert.equal(secondNames.includes('apply_patch'), false); + assert.equal(secondNames.includes('Write'), true); + assert.equal(secondNames.includes('Edit'), true); + }); + test('replays a durable apply_patch failure as native provider JSON', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ @@ -16019,11 +16107,15 @@ function compactPrompt(model: MockLanguageModelV4): unknown { } function modelToolNames(model: MockLanguageModelV4): string[] { - return sortedModelToolNames(Object.keys(modelTools(model))); + return modelToolNamesAt(model, 0); +} + +function modelToolNamesAt(model: MockLanguageModelV4, callIndex: number): string[] { + return sortedModelToolNames(Object.keys(modelTools(model, callIndex))); } -function modelTools(model: MockLanguageModelV4): Record { - const call = model.doStreamCalls[0] as unknown as Record | undefined; +function modelTools(model: MockLanguageModelV4, callIndex = 0): Record { + const call = model.doStreamCalls[callIndex] as unknown as Record | undefined; const tools = call?.tools; if (!tools) return {}; if (Array.isArray(tools)) { diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index a48db6aa5f..8fe23c7d31 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -19,7 +19,9 @@ import { nextId } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; -import { resolve } from 'node:path'; +import { lstat, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { test } from 'node:test'; import { type LlmConnection } from '@maka/core/llm-connections'; @@ -446,6 +448,50 @@ test('persistent path denies cover literal paths used by Bash', async () => { } }); +test('persistent path denies canonicalize case-insensitive macOS paths', { + skip: process.platform !== 'darwin', +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-permission-case-')); + const actualPath = join(root, 'Secret.txt'); + const differentlyCasedPath = join(root, 'secret.txt'); + try { + await writeFile(actualPath, 'secret'); + // Keep this assertion scoped to a case-insensitive volume. A case-sensitive + // macOS volume must continue treating the differently cased path as a + // distinct (and currently missing) path. + if ( + !(await lstat(differentlyCasedPath).then( + () => true, + () => false, + )) + ) + return; + const h = harness(); + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: [], + denyPaths: [{ path: differentlyCasedPath, scope: 'exact' }], + }), + }); + const tool: MakaTool = { + name: 'Read', + description: 'test', + parameters: z.object({ path: z.string() }), + impl: async () => assert.fail('case-insensitive path deny must prevent execution'), + }; + const { result } = await settle( + h, + tool, + { path: actualPath }, + { runtime, toolCallId: 'call_macos_case_path' }, + ); + assert.match((result as { error: string }).error, /persistent permission rule/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('an existing ToolRuntime reads updated persistent rules before the next dispatch', async () => { const h = harness(); let rules = normalizePermissionRules({ denyCommands: [], denyPaths: [] }); @@ -522,6 +568,31 @@ test('persistent path denies cover recursive Glob and Grep search scopes', async } }); +test('persistent path denies fail closed when command candidate count is unbounded', async () => { + const h = harness(); + const runtime = createTestToolRuntime({ + ...runtimeInput(h), + permissionRules: normalizePermissionRules({ + denyCommands: [], + denyPaths: [{ path: resolve('/workspace', 'blocked.txt'), scope: 'exact' }], + }), + }); + const tool: MakaTool = { + name: 'Bash', + description: 'test', + parameters: z.object({ command: z.string() }), + impl: async () => assert.fail('unbounded path candidates must prevent execution'), + }; + const command = Array.from({ length: 129 }, (_, index) => `value-${index}.txt`).join(' '); + const { result } = await settle( + h, + tool, + { command }, + { runtime, toolCallId: 'call_too_many_path_candidates' }, + ); + assert.match((result as { error: string }).error, /too many paths/); +}); + test('persistent command denies cover split and action-based PTY input', async () => { const h = harness(); let implementationCalls = 0; @@ -597,6 +668,56 @@ test('persistent command denies cover split and action-based PTY input', async ( assert.equal(implementationCalls, 1); }); +test('persistent command denies use the same fragment matching for Bash and PTY', async () => { + const commands = [' true && git push origin main', 'echo x; /usr/bin/git push origin main']; + for (const [index, command] of commands.entries()) { + const bashHarness = harness(); + const bashRuntime = createTestToolRuntime({ + ...runtimeInput(bashHarness), + permissionRules: normalizePermissionRules({ + denyCommands: ['git push *'], + denyPaths: [], + }), + }); + const bashTool: MakaTool = { + name: 'Bash', + description: 'test', + parameters: z.object({ command: z.string() }), + impl: async () => assert.fail('Bash command deny must prevent execution'), + }; + const bashResult = await settle( + bashHarness, + bashTool, + { command }, + { runtime: bashRuntime, toolCallId: `call_bash_fragment_${index}` }, + ); + assert.match((bashResult.result as { error: string }).error, /persistent permission rule/); + + const ptyHarness = harness(); + const ptyRuntime = createTestToolRuntime({ + ...runtimeInput(ptyHarness), + permissionRules: normalizePermissionRules({ + denyCommands: ['git push *'], + denyPaths: [], + }), + permissionRuntimeState: createPermissionRuntimeState(), + }); + const ptyTool: MakaTool = { + name: 'WriteStdin', + description: 'test', + parameters: z.object({ ref: z.string(), input: z.string().optional() }), + impl: async () => assert.fail('PTY command deny must prevent execution'), + }; + const ptyResult = await settle( + ptyHarness, + ptyTool, + { ref: `pty-${index}`, input: `${command}\n` }, + { runtime: ptyRuntime, toolCallId: `call_pty_fragment_${index}` }, + ); + assert.match((ptyResult.result as { error: string }).error, /persistent permission rule/); + } +}); + test('persistent PTY rules fail closed for cursor editing and oversized fragments', async () => { const h = harness(); let implementationCalls = 0; diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index c0992edc3f..ead7ab2424 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -75,7 +75,7 @@ import { type MemoryExtractionTrigger, } from './memory-extraction.js'; import { modelUsesNativeOpenAiResponses, resolveModelRuntime } from './model-runtime.js'; -import { routeApplyPatchTools } from './apply-patch-profile.js'; +import { isNativeApplyPatchAllowed, type ApplyPatchProfile } from './apply-patch-profile.js'; import { bindToolResultArchiveDecoder } from './tool-result-archive-capability.js'; import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; export { @@ -270,6 +270,7 @@ export class AiSdkBackend implements AgentBackend { private readonly maxSteps: number | undefined; private readonly providerRetrySleep: (delayMs: number, signal: AbortSignal) => Promise; private readonly modelAdapter: ModelAdapter; + private readonly applyPatchProfile: ApplyPatchProfile | null; private readonly messageProjection: AiSdkMessageProjection; private readonly providerTelemetry: ProviderRequestTelemetry; private readonly resolvedProviderOptions: Record; @@ -353,13 +354,11 @@ export class AiSdkBackend implements AgentBackend { beforeRunProviderDispatch: input.beforeRunProviderDispatch, }); const runtime = resolveModelRuntime(input.connection, input.modelId); - const applyPatchProfile = - input.permissionRules === undefined || input.permissionRules.denyPaths.length === 0 - ? runtime.applyPatchProfile - : null; + this.applyPatchProfile = runtime.applyPatchProfile; this.messageProjection = new AiSdkMessageProjection({ modelAdapter: this.modelAdapter, - applyPatchProfile, + applyPatchProfile: this.applyPatchProfile, + readApplyPatchProfile: () => this.currentApplyPatchProfile(), supportsVision: input.supportsVision, readAttachmentBytes: input.readAttachmentBytes, maxProviderImageRequestBytes: input.maxProviderImageRequestBytes, @@ -410,16 +409,20 @@ export class AiSdkBackend implements AgentBackend { : {}), }) : []; - const modelTools = routeApplyPatchTools(input.tools, applyPatchProfile); this.toolAvailabilityRuntime = new ToolAvailabilityRuntime( // The archive decoder is a runtime protocol tool, not a host binding: // this session's placeholders name it, so this session advertises it. - bindToolResultArchiveDecoder([...modelTools, ...memoryTools], input.toolResultArchive), + bindToolResultArchiveDecoder([...input.tools, ...memoryTools], input.toolResultArchive), input.toolAvailability, buildInvalidMakaTool(), ); } + private currentApplyPatchProfile(): ApplyPatchProfile | null { + const rules = this.input.readPermissionRules?.() ?? this.input.permissionRules; + return isNativeApplyPatchAllowed(rules) ? this.applyPatchProfile : null; + } + private memorySourceSnapshot( trigger: MemoryExtractionTrigger, context: MakaToolContext, @@ -502,6 +505,7 @@ export class AiSdkBackend implements AgentBackend { providerTelemetry: this.providerTelemetry, compaction: this.compaction, toolAvailabilityRuntime: this.toolAvailabilityRuntime, + readApplyPatchProfile: () => this.currentApplyPatchProfile(), codeCellAdmission: this.codeCellAdmission, resolvedProviderOptions: this.resolvedProviderOptions, session: this.turnSessionState, diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index c30e70d0f9..e6b22216fb 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -63,6 +63,7 @@ import { toolResultOutput } from './tool-result-output.js'; export interface AiSdkMessageProjectionInput { modelAdapter: ModelAdapter; applyPatchProfile: ApplyPatchProfile | null; + readApplyPatchProfile?: () => ApplyPatchProfile | null; supportsVision?: boolean; readAttachmentBytes?: AttachmentByteReader; maxProviderImageRequestBytes?: number; @@ -145,6 +146,12 @@ export class AiSdkMessageProjection { constructor(private readonly input: AiSdkMessageProjectionInput) {} + private currentApplyPatchProfile(): ApplyPatchProfile | null { + return this.input.readApplyPatchProfile + ? this.input.readApplyPatchProfile() + : this.input.applyPatchProfile; + } + canReplayProviderNative(plan: RuntimeEventModelReplayPlan): boolean { const support = this.input.modelAdapter.runtimeEventReplaySupport(); for (const item of plan.items) { @@ -485,7 +492,7 @@ export class AiSdkMessageProjection { continue; } const replayInput = normalizeApplyPatchReplayInput( - this.input.applyPatchProfile, + this.currentApplyPatchProfile(), call.toolCallId, call.input, ); diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index c501a66ac7..4e95299575 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -154,6 +154,7 @@ import { renderSwarmModePrompt } from './swarm-mode.js'; import { renderGraphModePrompt } from './graph-mode.js'; import type { MemoryExtractionSourceSnapshot } from './memory-extraction.js'; import { modelUsesNativeOpenAiResponses } from './model-runtime.js'; +import { routeApplyPatchTools, type ApplyPatchProfile } from './apply-patch-profile.js'; import { applyRuntimeEventContextBudget, buildContextBudgetDiagnosticShell, @@ -191,6 +192,7 @@ export interface AiSdkTurnDependencies { providerTelemetry: ProviderRequestTelemetry; compaction: AiSdkCompaction; toolAvailabilityRuntime: ToolAvailabilityRuntime; + readApplyPatchProfile: () => ApplyPatchProfile | null; codeCellAdmission: AdmissionLimiter; resolvedProviderOptions: Record; session: AiSdkSessionState; @@ -456,6 +458,30 @@ function projectToolModePlan( }; } +/** Re-project the provider tool surface when the Host changes live path rules. */ +function routeApplyPatchToolPlan( + plan: ToolAvailabilityPlan, + profile: ApplyPatchProfile | null, +): ToolAvailabilityPlan { + const providerTools = routeApplyPatchTools(plan.providerTools, profile); + const visibleNames = new Set(providerTools.map((tool) => tool.name)); + const filterNames = (names: readonly string[]): string[] => + names.filter((name) => visibleNames.has(name)); + return { + ...plan, + providerTools, + activeTools: filterNames(plan.activeTools), + ...(plan.projectActiveTools + ? { + projectActiveTools: (options) => ({ + activeTools: filterNames(plan.projectActiveTools!(options).activeTools), + }), + } + : {}), + currentRepairToolNames: () => filterNames(plan.currentRepairToolNames()), + }; +} + function nestableToolSnapshot( providerTools: readonly MakaTool[], activeToolNames: readonly string[], @@ -1098,7 +1124,6 @@ export class AiSdkTurn { toolMode, codeModeExecTool, ); - const providerTools = plan.providerTools; let activeToolResultPruneDiagnosticPatch: ActiveToolResultPruneDiagnosticPatch = {}; let midTurnCompactDiagnosticPatch: Partial | undefined; // Tool names the repair path matches a mis-cased call against — follows the @@ -1110,22 +1135,14 @@ export class AiSdkTurn { ? names.filter((name) => name !== REQUEST_SANDBOX_BOUNDARY_TOOL_NAME) : [...names]; }; - const currentRepairToolNames = () => boundaryAwareToolNames(plan.currentRepairToolNames()); + const currentRepairToolNames = () => + boundaryAwareToolNames( + routeApplyPatchToolPlan(plan, this.deps.readApplyPatchProfile()).currentRepairToolNames(), + ); if (plan.gating) { toolRuntime.setGating(plan.gating); } - const modelTools: ModelToolSet = {}; - for (const t of providerTools) { - modelTools[t.name] = t.providerTool - ? { kind: 'provider', providerTool: t.providerTool } - : { - kind: 'function', - description: t.description, - inputSchema: t.parameters, - }; - } - // Resolve the stable Provider envelope before automatic Compaction freezes // its source. The same value is reused by the primary request; Memory does // not resolve or mutate Agent configuration after the checkpoint commits. @@ -1264,7 +1281,11 @@ export class AiSdkTurn { this.watchdog = next; next.start(); }; - const activeTools = plan.activeTools; + const initialProviderPlan = routeApplyPatchToolPlan( + plan, + this.deps.readApplyPatchProfile(), + ); + const activeTools = initialProviderPlan.activeTools; const currentUserContent = input.continuation ? undefined : await this.deps.messageProjection.buildCurrentUserContent( @@ -1362,8 +1383,9 @@ export class AiSdkTurn { // terminal trace is refined against the final active set below. contextBudgetForTelemetry = priorReplay.contextBudget; const computeToolAvailability = (active: readonly string[]) => { - const toolSchemaChars = toolSchemaCharsForDiagnostics(providerTools, active); - return plan.diagnostics(active, toolSchemaChars); + const providerPlan = routeApplyPatchToolPlan(plan, this.deps.readApplyPatchProfile()); + const toolSchemaChars = toolSchemaCharsForDiagnostics(providerPlan.providerTools, active); + return providerPlan.diagnostics(active, toolSchemaChars); }; toolAvailabilityForTelemetry = computeToolAvailability(activeTools); trace.modelStreamStarted(activeTools, { @@ -1383,7 +1405,7 @@ export class AiSdkTurn { turnId, midTurnState, queue, - providerTools, + initialProviderPlan.providerTools, onMidTurnDiagnosticPatch, this, this.automaticMemoryCompactionSupported() @@ -1409,7 +1431,12 @@ export class AiSdkTurn { }, ); const shapedProjection = composeRequestProjection( - plan.projectActiveTools, + plan.projectActiveTools + ? (context) => ({ + activeTools: routeApplyPatchToolPlan(plan, this.deps.readApplyPatchProfile()) + .projectActiveTools!(context).activeTools, + }) + : undefined, midTurnCapacityHook, activeToolResultPruneHook, ); @@ -1458,6 +1485,19 @@ export class AiSdkTurn { if (sandboxBoundaryFinalizationStep) { toolRuntime.forceSandboxBoundaryFinalization(); } + const providerPlan = routeApplyPatchToolPlan(plan, this.deps.readApplyPatchProfile()); + const providerTools = providerPlan.providerTools; + const modelTools: ModelToolSet = {}; + for (const t of providerTools) { + modelTools[t.name] = t.providerTool + ? { kind: 'provider', providerTool: t.providerTool } + : { + kind: 'function', + description: t.description, + inputSchema: t.parameters, + }; + } + const providerToolNames = new Set(providerTools.map((tool) => tool.name)); const requestSystemPrompt = joinPromptFragments([ systemPrompt, finalChildSummaryStep ? CHILD_STEP_BUDGET_FINALIZATION_PROMPT : undefined, @@ -1471,7 +1511,11 @@ export class AiSdkTurn { activeTools: finalChildSummaryStep || sandboxBoundaryFinalizationStep ? [] - : boundaryAwareToolNames(active ?? plan.currentRepairToolNames()), + : boundaryAwareToolNames( + (active ?? providerPlan.currentRepairToolNames()).filter((name) => + providerToolNames.has(name), + ), + ), }); const shaped = requestProjection ? await requestProjection({ diff --git a/packages/runtime/src/apply-patch-profile.ts b/packages/runtime/src/apply-patch-profile.ts index a27b8d7ea9..8e59fe838c 100644 --- a/packages/runtime/src/apply-patch-profile.ts +++ b/packages/runtime/src/apply-patch-profile.ts @@ -18,6 +18,7 @@ */ import type { ApplyPatchProtocol } from '@maka/core/llm-connections'; +import type { PermissionRules } from '@maka/core/runtime-policy'; import { parseCodexV4aPatch } from './codex-v4a-patch.js'; import type { ApplyPatchOperation } from './filesystem-executor.js'; import type { ModelRuntimeWire } from './model-runtime.js'; @@ -31,6 +32,11 @@ export interface ApplyPatchProfileRuntime { readonly applyPatchProtocol?: ApplyPatchProtocol; } +/** Native ApplyPatch cannot be guarded by ToolRuntime while path denies exist. */ +export function isNativeApplyPatchAllowed(rules: PermissionRules | undefined): boolean { + return rules === undefined || rules.denyPaths.length === 0; +} + /** Resolve the exact provider/model/wire contract; unknown combinations fail closed. */ export function resolveApplyPatchProfile( runtime: ApplyPatchProfileRuntime, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 1bfc86668e..baa14b6f9e 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -146,6 +146,13 @@ import { import { realpathAllowMissing } from './path-containment.js'; const MAX_PENDING_PTY_COMMAND_INPUT_CHARS = 64 * 1024; +const MAX_PERMISSION_PATH_CANDIDATES = 128; + +interface ResolvedPermissionRules { + readonly rules: PermissionRules; + /** Original configured spellings, aligned with rules.denyPaths. */ + readonly displayPaths: readonly string[]; +} export interface ResolvedMakaToolCall { tool: MakaTool; @@ -3332,18 +3339,24 @@ export class ToolRuntime { ): Promise { const configuredRules = this.input.readPermissionRules?.() ?? this.input.permissionRules; if (!configuredRules) return undefined; - const rules = permissionRulesForCurrentHost(configuredRules); - const matcher = compilePermissionRules(rules); + let resolvedRules: ResolvedPermissionRules; + try { + resolvedRules = await this.resolvePermissionRules(configuredRules); + } catch { + return `${tool.name} was denied by a persistent permission rule because its paths could not be verified safely.`; + } + const { rules } = resolvedRules; if (tool.name === 'Bash' && isRecord(args) && typeof args.command === 'string') { - const match = matcher.match({ command: args.command }); - if (match?.kind === 'command') { - return `Tool Bash was denied by a persistent permission rule matching ${JSON.stringify(match.pattern)}.`; + const match = matchPermissionCommandFragments(rules, args.command); + if (match !== undefined) { + return `Tool Bash was denied by a persistent permission rule matching ${JSON.stringify(match)}.`; } const pathDenial = await this.findPersistentPathDenial( tool.name, commandPathCandidates(args.command), rules, + resolvedRules.displayPaths, ); if (pathDenial !== undefined) return pathDenial; } @@ -3364,6 +3377,7 @@ export class ToolRuntime { tool.name, commandPathCandidates(`${prior}${input.text}`), rules, + resolvedRules.displayPaths, ); if (pathDenial !== undefined) return pathDenial; if (inspection.pending.length > MAX_PENDING_PTY_COMMAND_INPUT_CHARS) { @@ -3379,7 +3393,12 @@ export class ToolRuntime { const paths = permissionPathsForTool(tool.name, args, this.input.header.cwd); if (paths.length === 0) return undefined; - const pathDenial = await this.findPersistentPathDenial(tool.name, paths, rules); + const pathDenial = await this.findPersistentPathDenial( + tool.name, + paths, + rules, + resolvedRules.displayPaths, + ); if (pathDenial !== undefined) return pathDenial; if (rules.denyPaths.length === 0) return undefined; if (tool.name === 'Glob' || tool.name === 'Grep') { @@ -3396,7 +3415,7 @@ export class ToolRuntime { permissionPathWithinRoot(canonicalRoot, rule.path) || permissionPathWithinRoot(rule.path, canonicalRoot) ) { - return `${tool.name} was denied by a persistent permission rule because its search scope includes ${rule.scope} path ${JSON.stringify(rule.path)}.`; + return `${tool.name} was denied by a persistent permission rule because its search scope includes ${rule.scope} path ${JSON.stringify(resolvedRules.displayPaths[rules.denyPaths.indexOf(rule)] ?? rule.path)}.`; } } } @@ -3408,24 +3427,54 @@ export class ToolRuntime { toolName: string, paths: readonly string[], rules: PermissionRules, + displayPaths: readonly string[], ): Promise { if (rules.denyPaths.length === 0) return undefined; + const candidates = [...new Set(paths)].filter((path) => path.length > 0); + if (candidates.length > MAX_PERMISSION_PATH_CANDIDATES) { + return `${toolName} was denied by a persistent permission rule because too many paths were supplied to verify safely.`; + } const matcher = compilePermissionRules(rules); - for (const path of paths) { - let canonicalPath: string; - try { - canonicalPath = await this.resolvePermissionPath(path); - } catch { - return `${toolName} was denied by a persistent permission rule because its path could not be verified safely.`; - } + let canonicalPaths: string[]; + try { + canonicalPaths = await Promise.all( + candidates.map((path) => this.resolvePermissionPath(path)), + ); + } catch { + return `${toolName} was denied by a persistent permission rule because its path could not be verified safely.`; + } + for (const canonicalPath of canonicalPaths) { const match = matcher.match({ path: canonicalPath }); if (match?.kind === 'path') { - return `${toolName} was denied by a persistent permission rule for ${match.rule.scope} path ${JSON.stringify(match.rule.path)}.`; + const index = rules.denyPaths.indexOf(match.rule); + return `${toolName} was denied by a persistent permission rule for ${match.rule.scope} path ${JSON.stringify(displayPaths[index] ?? match.rule.path)}.`; } } return undefined; } + private async resolvePermissionRules(rules: PermissionRules): Promise { + const hostRules = permissionRulesForCurrentHost(rules); + if (hostRules.denyPaths.length === 0) { + return { rules: hostRules, displayPaths: hostRules.denyPaths.map((rule) => rule.path) }; + } + const displayPaths = hostRules.denyPaths.map( + (_rule, index) => rules.denyPaths[index]?.path ?? hostRules.denyPaths[index]!.path, + ); + const denyPaths = await Promise.all( + hostRules.denyPaths.map(async (rule) => + Object.freeze({ ...rule, path: await this.resolvePermissionPath(rule.path) }), + ), + ); + return { + rules: Object.freeze({ + denyCommands: hostRules.denyCommands, + denyPaths: Object.freeze(denyPaths), + }), + displayPaths, + }; + } + private async resolvePermissionPath(path: string): Promise { const requested = isAbsolute(path) ? resolve(path) : resolve(this.input.header.cwd, path); return await realpathAllowMissing(requested); @@ -3610,10 +3659,38 @@ function matchPermissionCommandFragments( rules: PermissionRules, input: string, ): string | undefined { - const value = input.trim(); - if (value.length === 0) return undefined; + // This deliberately remains a fragment splitter, not a shell parser. Bash + // and PTY enforcement must agree on the conservative command boundaries, + // while expansion, substitution, and other shell grammar stay outside this + // matcher’s contract. + // PTY calls this helper after every keystroke. Its pending buffer has + // already removed separators, so keep that hot path linear in the current + // fragment instead of rescanning and splitting the whole buffer each time. + const fragments = /[\r\n;|&]/.test(input) ? input.split(/[\r\n;|&]/) : [input]; + for (const fragment of fragments) { + const value = fragment.trim(); + if (value.length === 0) continue; + const match = matchPermissionCommandValue(rules, value); + if (match !== undefined) return match; + } + return undefined; +} + +function matchPermissionCommandValue(rules: PermissionRules, value: string): string | undefined { const match = matchPermissionRules(rules, { command: value }); - return match?.kind === 'command' ? match.pattern : undefined; + if (match?.kind === 'command') return match.pattern; + + // A rule normally names the executable (`git push *`), while a shell may + // invoke it through an absolute path (`/usr/bin/git push ...`). Match one + // basename-normalized variant without attempting to interpret shell syntax. + const executableMatch = /^(?:"([^"]*)"|'([^']*)'|([^\s;|&]+))/.exec(value); + const executable = executableMatch?.[1] ?? executableMatch?.[2] ?? executableMatch?.[3]; + if (!executable || !/[\\/]/.test(executable)) return undefined; + const basename = executable.replace(/^.*[\\/]/, ''); + if (basename.length === 0 || basename === executable) return undefined; + const basenameVariant = `${basename}${value.slice(executableMatch![0].length)}`; + const basenameMatch = matchPermissionRules(rules, { command: basenameVariant }); + return basenameMatch?.kind === 'command' ? basenameMatch.pattern : undefined; } function globPatternBase(pattern: string): string { From e05e20defc861be4c2f95662b5337b81594201e9 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 6 Sep 2026 15:11:40 +0800 Subject: [PATCH 7/7] fix(ci): refresh Windows test inventory --- docs/windows-test-inventory.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index d13667bfd7..1767fc7c0f 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 18 | -| platform-contract | 31 | +| platform-contract | 32 | -Total Windows-excluded declarations: **76** +Total Windows-excluded declarations: **77** ## Inventory @@ -66,6 +66,7 @@ Total Windows-excluded declarations: **76** | platform-contract | `packages/runtime/src/__tests__/filesystem-worker-process-runner.test.ts` filesystem worker rejects boundedly when a detached descendant retains stdout | `process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false` | | platform-contract | `packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts` macOS filesystem worker smoke | `process.platform !== 'darwin'` | | portable-candidate | `packages/runtime/src/__tests__/node-pty-write-lifecycle.test.ts` does not carry queued Unix PTY writes past native exit | `process.platform === 'win32' ? 'Unix PTY file-descriptor lifecycle only' : false` | +| platform-contract | `packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts` persistent path denies canonicalize case-insensitive macOS paths | `process.platform !== 'darwin'` | | portable-candidate | `packages/runtime/src/__tests__/shell-exec.test.ts` writes a legacy WSL Bash command through stdin | `process.platform === 'win32' ? 'uses /bin/sh as a portable stdin probe' : false` | | platform-contract | `packages/runtime/src/__tests__/shell-exec.test.ts` bounds output drain after the root exits while a detached descendant retains stdout | `process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false` | | platform-contract | `packages/runtime/src/__tests__/shell-run-manager.test.ts` latches timeout when the root exits during POSIX process discovery | `process.platform === 'win32' ? 'POSIX process discovery only' : false` |