diff --git a/.github/hooks/agent-permissions.json b/.github/hooks/agent-permissions.json new file mode 100644 index 00000000000..9d483a7e350 --- /dev/null +++ b/.github/hooks/agent-permissions.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "matcher": "bash|powershell|view|create|edit|str_replace_editor|apply_patch", + "exec": "node", + "args": [".github/scripts/agent-permissions.mjs"], + "cwd": ".", + "timeoutSec": 10 + } + ] + } +} diff --git a/.github/scripts/agent-permissions.mjs b/.github/scripts/agent-permissions.mjs new file mode 100644 index 00000000000..ba1612a2f4f --- /dev/null +++ b/.github/scripts/agent-permissions.mjs @@ -0,0 +1,368 @@ +import { existsSync, realpathSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repositoryRoot = realpathSync(fileURLToPath(new URL('../..', import.meta.url))); +const readOnlyGitCommands = new Set(['status', 'diff', 'log', 'show', 'ls-files', 'ls-tree', 'rev-parse', 'describe', 'blame', 'grep']); +const approvedGitHubCommands = new Set([ + 'issue list', + 'issue view', + 'issue status', + 'pr list', + 'pr view', + 'pr status', + 'pr diff', + 'pr checks', + 'run list', + 'run view', + 'run watch', + 'workflow list', + 'workflow view', + 'repo view', + 'auth status', + 'auth switch', +]); +const nativeDriverEnvironment = new Set([ + 'FURN_NATIVE_DRIVER_TEST', + 'FURN_DESKTOP_DRIVER_BUILD_POLICY', + 'FURN_DESKTOP_DRIVER_CACHE_ROOT', + 'FURN_DESKTOP_DRIVER_INSTALL_ROOT', + 'FURN_DESKTOP_DRIVER_CONFIGURATION', + 'FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY', + 'FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES', +]); + +function isWithinRepository(path, cwd) { + let absolute = resolve(cwd, path); + // Resolve existing ancestors too, so a new file beneath an external symlink is not approved. + for (;;) { + try { + absolute = realpathSync(absolute); + break; + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + const parent = dirname(absolute); + if (parent === absolute) { + return false; + } + absolute = parent; + } + } + const fromRoot = relative(repositoryRoot, absolute); + return fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot); +} + +function commandWords(command) { + // Deliberately accept only simple commands shared by Bash and PowerShell, not a shell language. + if (typeof command !== 'string' || /[;&|<>$`(){}\r\n\\]/.test(command)) { + return undefined; + } + const words = []; + const token = /(?:^|\s+)("[^"]*"|'[^']*'|[^\s"'#]+)(?=\s|$)/gy; + let offset = 0; + while (offset < command.length) { + token.lastIndex = offset; + const match = token.exec(command); + if (!match) { + return command.slice(offset).trim() === '' ? words : undefined; + } + const word = match[1]; + if ( + !word.startsWith('"') && + !word.startsWith("'") && + (/[*?[\]~]/.test(word) || (word.startsWith('@') && !/^@[a-z\d-]+\/[a-z\d._-]+$/i.test(word))) + ) { + return undefined; + } + words.push(word.startsWith('"') || word.startsWith("'") ? word.slice(1, -1) : word); + offset = token.lastIndex; + } + return words; +} + +function allowGit(args, cwd) { + while (args.length) { + if (args[0] === '--no-pager' || args[0] === '--literal-pathspecs') { + args = args.slice(1); + } else if (args[0] === '-C' && args[1] && isWithinRepository(args[1], cwd)) { + cwd = resolve(cwd, args[1]); + args = args.slice(2); + } else { + break; + } + } + const [subcommand, ...options] = args; + const externalOptions = ['--output', '--ext-diff', '--textconv', '--open-files-in-pager']; + if ( + options.some( + (arg) => + (arg.startsWith('--') && arg !== '--' && externalOptions.some((option) => option.startsWith(arg.split('=')[0]))) || + (subcommand === 'grep' && arg.startsWith('-O')), + ) + ) { + return false; + } + if (readOnlyGitCommands.has(subcommand) || subcommand === 'add') { + return true; + } + if (subcommand === 'commit') { + // Git accepts abbreviated long options, including --am for --amend. + return !options.some((arg) => arg.startsWith('--') && '--amend'.startsWith(arg.split('=')[0])); + } + if (subcommand === 'branch') { + return options.every((arg) => ['--show-current', '--list', '-a', '--all', '-r', '--remotes', '-v', '-vv'].includes(arg)); + } + if (subcommand === 'fetch') { + return options.every((arg) => + ['--all', '--tags', '--no-tags', '--no-prune', '--dry-run', '--verbose', '--quiet', 'origin'].includes(arg), + ); + } + return false; +} + +function allowGitHubApi(args) { + let endpoint; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (['--paginate', '--slurp', '--silent', '--include'].includes(arg)) { + continue; + } + if (['--method', '-X', '--jq', '-q', '--hostname'].includes(arg)) { + const value = args[++index]; + if (!value || ((arg === '--method' || arg === '-X') && value !== 'GET') || (arg === '--hostname' && value !== 'github.com')) { + return false; + } + continue; + } + if (arg === '--method=GET' || arg === '-XGET' || arg === '--hostname=github.com' || arg.startsWith('--jq=')) { + continue; + } + if (arg.startsWith('-') || endpoint) { + return false; + } + endpoint = arg; + } + // Field/input options can implicitly turn GET into POST. GraphQL can contain mutations. + return !!endpoint && /^(?:\/?user(?:\/|\?|$)|\/?repos\/|\/?search\/)/.test(endpoint) && !endpoint.includes('://'); +} + +function allowPods(args, cwd) { + const [action, ...options] = args; + if (action !== 'install' && action !== 'update') { + return false; + } + let project = cwd; + let hasProjectDirectory = false; + let podCount = 0; + for (let index = 0; index < options.length; index++) { + const option = options[index]; + if (option === '--project-directory' || option.startsWith('--project-directory=')) { + const directory = option === '--project-directory' ? options[++index] : option.slice('--project-directory='.length); + if (!directory || hasProjectDirectory) { + return false; + } + hasProjectDirectory = true; + project = resolve(cwd, directory); + } else if ( + !['--repo-update', '--no-repo-update', '--deployment', '--clean-install', '--verbose', '--silent', '--no-ansi'].includes(option) + ) { + if (action !== 'update' || !/^[A-Za-z][A-Za-z0-9_.+-]*(?:\/[A-Za-z0-9_.+-]+)*$/.test(option)) { + return false; + } + podCount++; + } + } + if ((action === 'update' && podCount === 0) || !isWithinRepository(project, cwd) || !existsSync(resolve(project, 'Podfile'))) { + return false; + } + const nativeRoot = realpathSync(project); + const workingRoot = realpathSync(cwd); + return ( + /^apps\/[^/]+\/(?:ios|macos)$/.test(relative(repositoryRoot, nativeRoot).split(sep).join('/')) && + (workingRoot === nativeRoot || workingRoot === dirname(nativeRoot)) + ); +} + +function allowProcessInspection(executable, args) { + if (executable === 'ps') { + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (['-a', '-x', '-ax'].includes(arg)) { + continue; + } + if (arg === '-p' && /^[1-9]\d*(?:,[1-9]\d*)*$/.test(args[index + 1] ?? '')) { + index++; + } else if ( + ['-o', '-axo'].includes(arg) && + /^(?:(?:pid|ppid|lstart|etime|stat|comm|command|args|user|uid|pcpu|pmem)=?,?)+$/.test(args[index + 1] ?? '') + ) { + index++; + } else { + return false; + } + } + return true; + } + if (executable === 'lsof') { + let selected = false; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (['-nP', '-n', '-P', '-a', '-Fn', '-sTCP:LISTEN'].includes(arg)) { + continue; + } + if (/^-iTCP:[1-9]\d{0,4}$/.test(arg) && Number(arg.slice('-iTCP:'.length)) <= 65535) { + selected = true; + } else if (arg === '-p' && /^[1-9]\d*$/.test(args[index + 1] ?? '')) { + selected = true; + index++; + } else if (arg === '-d' && ['cwd', 'txt'].includes(args[index + 1])) { + index++; + } else { + return false; + } + } + return selected; + } + return false; +} + +function allowLoopbackProbe(args) { + // Ignore ~/.curlrc so a status probe cannot inherit uploads, redirects, or output files. + if (args[0] !== '-q') { + return false; + } + let url; + for (let index = 1; index < args.length; index++) { + const arg = args[index]; + if (['--fail', '--silent', '--show-error', '--head'].includes(arg) || /^-[fsSI]+$/.test(arg)) { + continue; + } + if (['--max-time', '--connect-timeout'].includes(arg) && /^[1-9]\d*$/.test(args[index + 1] ?? '')) { + index++; + continue; + } + const match = /^http:\/\/(?:127\.0\.0\.1|localhost):([1-9]\d{0,4})\/(?:status|index\.json)$/.exec(arg); + if (url || !match || Number(match[1]) > 65535) { + return false; + } + url = arg; + } + return !!url; +} + +function allowCommand(command, cwd, toolName) { + const words = commandWords(command); + if (!words?.length) { + return false; + } + const [executable, ...args] = words; + if (executable === 'yarn') { + return true; + } + if (toolName === 'bash') { + const environment = executable === 'env' ? args : words; + const yarnIndex = environment.indexOf('yarn'); + if ( + yarnIndex > 0 && + (executable === 'env' || /^(?:FURN_[A-Z_]+=[^\s"'#]+\s+)+yarn(?:\s|$)/.test(command)) && + environment.slice(0, yarnIndex).every((arg) => nativeDriverEnvironment.has(arg.split('=')[0]) && arg.includes('=')) + ) { + return true; + } + } + if (executable === 'pod') { + return allowPods(args, cwd); + } + if (executable === 'bundle' && args[0] === 'exec' && args[1] === 'pod') { + return allowPods(args.slice(2), cwd); + } + if (executable === 'ps' || executable === 'lsof') { + return toolName === 'bash' && allowProcessInspection(executable, args); + } + if (executable === 'curl') { + return toolName === 'bash' && allowLoopbackProbe(args); + } + if (executable === 'git') { + return allowGit(args, cwd); + } + if (executable !== 'gh') { + return false; + } + if (args[0] === 'api') { + return allowGitHubApi(args.slice(1)); + } + return approvedGitHubCommands.has(args.slice(0, 2).join(' ')) && !args.some((arg) => arg.startsWith('--show-token')); +} + +function allowShell(command, cwd, toolName) { + if (typeof command !== 'string') { + return false; + } + // Each && segment must independently qualify; no pipelines, substitutions, or other shell syntax. + const commands = command.split('&&').map((part) => part.trim()); + let ranCommand = false; + for (const part of commands) { + const words = commandWords(part); + if (words?.[0] === 'cd' && words.length === 2 && !words[1].startsWith('-') && !/[*?[\]]/.test(words[1])) { + if (toolName === 'bash' && process.env.CDPATH && !isAbsolute(words[1]) && !/^\.{1,2}(?:\/|$)/.test(words[1])) { + return false; + } + const directory = resolve(cwd, words[1]); + if (!existsSync(directory) || !isWithinRepository(directory, cwd)) { + return false; + } + cwd = realpathSync(directory); + } else if (allowCommand(part, cwd, toolName)) { + ranCommand = true; + } else { + return false; + } + } + return ranCommand; +} + +function patchPaths(patch) { + if (typeof patch !== 'string' || !patch.startsWith('*** Begin Patch\n') || !patch.trimEnd().endsWith('*** End Patch')) { + return []; + } + return [...patch.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm)].map((match) => match[1]); +} + +export function permissionDecision(input) { + const { cwd, toolName } = input; + if (!['bash', 'powershell', 'view', 'create', 'edit', 'str_replace_editor', 'apply_patch'].includes(toolName)) { + return {}; + } + if (typeof cwd !== 'string' || !isAbsolute(cwd) || !isWithinRepository(cwd, repositoryRoot)) { + return {}; + } + const args = + toolName === 'apply_patch' && typeof input.toolArgs === 'string' && input.toolArgs.startsWith('*** Begin Patch\n') + ? { input: input.toolArgs } + : typeof input.toolArgs === 'string' + ? JSON.parse(input.toolArgs) + : input.toolArgs; + if (!args || typeof args !== 'object') { + return {}; + } + let allowed = false; + if (['bash', 'powershell'].includes(toolName)) { + allowed = allowShell(args.command, cwd, toolName); + } else if (['view', 'create', 'edit', 'str_replace_editor'].includes(toolName)) { + allowed = typeof args.path === 'string' && isWithinRepository(args.path, cwd); + } else if (toolName === 'apply_patch') { + const paths = patchPaths(args.input ?? args.patch); + allowed = paths.length > 0 && paths.every((path) => isWithinRepository(path, cwd)); + } + return allowed ? { permissionDecision: 'allow' } : {}; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + let input = ''; + for await (const chunk of process.stdin) { + input += chunk; + } + process.stdout.write(`${JSON.stringify(permissionDecision(JSON.parse(input)))}\n`); +} diff --git a/.github/scripts/agent-permissions.test.mjs b/.github/scripts/agent-permissions.test.mjs new file mode 100644 index 00000000000..f83534c2efb --- /dev/null +++ b/.github/scripts/agent-permissions.test.mjs @@ -0,0 +1,272 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +import { permissionDecision } from './agent-permissions.mjs'; + +const cwd = fileURLToPath(new URL('../..', import.meta.url)); +const allow = { permissionDecision: 'allow' }; +const shell = (command, toolName = 'bash', directory = cwd) => permissionDecision({ cwd: directory, toolName, toolArgs: { command } }); + +test('approves repository development commands in Bash and PowerShell', () => { + for (const tool of ['bash', 'powershell']) { + for (const command of [ + 'yarn', + 'yarn install --immutable', + 'yarn workspace @fluentui-react-native/design test', + 'git --no-pager status --short', + 'git -C packages diff --stat', + 'git add -- AGENTS.md', + 'git commit -m "Add shared permissions"', + 'git branch --show-current', + 'git fetch origin', + 'gh issue list --state open', + 'gh pr checks 123', + 'gh run view 123 --log-failed', + 'gh auth status', + 'gh auth switch --hostname github.com --user example', + 'gh api user --jq .login', + 'gh api --method GET repos/microsoft/fluentui-react-native/actions/runs --paginate', + 'gh api repos/microsoft/fluentui-react-native/issues -XGET', + ]) { + assert.deepEqual(shell(command, tool), allow, command); + } + } +}); + +test('approves package-owned native workflows, including workspace changes and native test configuration', () => { + for (const command of [ + 'cd ./apps/storybook && yarn storybook prep --macos', + 'cd ./apps/storybook && yarn storybook --verbose bundle --macos', + 'cd ./apps/storybook && yarn storybook instance --macos', + 'cd ./apps/storybook && yarn desktop-driver doctor --platform macos --permissions', + 'cd ./apps/storybook && yarn desktop-driver agent screenshot --platform macos --url http://127.0.0.1:38662 --target agenticstorybook-macos --artifacts artifacts/macos --name button', + 'cd ./packages/agentic/desktop-driver && yarn format && yarn lint && yarn build && yarn test --runInBand', + ]) { + for (const tool of ['bash', 'powershell']) { + assert.deepEqual(shell(command, tool), allow, command); + } + } + for (const command of [ + 'cd ./packages/agentic/desktop-driver && FURN_NATIVE_DRIVER_TEST=1 yarn test --runInBand', + 'FURN_NATIVE_DRIVER_TEST=1 FURN_DESKTOP_DRIVER_BUILD_POLICY=never yarn test', + 'env FURN_DESKTOP_DRIVER_DISABLED_INPUT_FEATURES=physicalClick yarn storybook smoke --macos', + 'env "FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY=FURN Development" yarn storybook build-driver --macos', + ]) { + assert.deepEqual(shell(command), allow, command); + } + for (const command of [ + 'cd .. && yarn', + 'cd ./apps/storybook && yarn build && git reset --hard', + 'cd ./apps/storybook; yarn build', + 'yarn build &&', + 'cd "./apps/story*" && yarn build', + 'env NODE_OPTIONS=--require=other.cjs yarn', + 'env PATH=/tmp yarn', + 'env FURN_NATIVE_DRIVER_TEST=1 node script.mjs', + '"FURN_NATIVE_DRIVER_TEST=1" yarn', + 'FURN_NATIVE_DRIVER_TEST=1 "FURN_DESKTOP_DRIVER_BUILD_POLICY=never" yarn', + 'FURN_NATIVE_DRIVER_TEST=1 yarn && kill 123', + ]) { + assert.deepEqual(shell(command), {}, command); + } +}); + +test('does not approve workspace changes that Bash CDPATH could redirect', () => { + const previous = process.env.CDPATH; + try { + process.env.CDPATH = tmpdir(); + assert.deepEqual(shell('cd apps/storybook && yarn'), {}); + assert.deepEqual(shell('cd .github && yarn'), {}); + assert.deepEqual(shell('cd ./apps/storybook && yarn'), allow); + assert.deepEqual(shell(`cd "${cwd}" && yarn`), allow); + } finally { + if (previous === undefined) { + delete process.env.CDPATH; + } else { + process.env.CDPATH = previous; + } + } +}); + +test('approves Pod preparation and named updates only from the owning app or native directory', () => { + const app = join(cwd, 'apps/storybook'); + for (const command of [ + 'pod install --project-directory=macos', + 'pod install --project-directory macos --repo-update --clean-install', + 'pod update React-Core React-Codegen --project-directory=macos --no-repo-update', + 'bundle exec pod install --project-directory=macos --verbose', + 'cd macos && pod install', + 'cd macos && pod update React-Core', + ]) { + assert.deepEqual(shell(command, 'bash', app), allow, command); + } + assert.deepEqual(shell('pod install', 'bash', join(app, 'macos')), allow); + for (const command of [ + 'pod update --project-directory=macos', + 'pod repo update', + 'pod deintegrate --project-directory=macos', + 'pod cache clean --all', + 'pod install --allow-root --project-directory=macos', + 'pod install --project-directory=../../..', + 'pod install --project-directory=/tmp', + 'pod install --project-directory=/tmp --project-directory=macos', + 'pod install', + 'bundle install', + ]) { + assert.deepEqual(shell(command, 'bash', app), {}, command); + } + assert.deepEqual(shell('pod install --project-directory=apps/storybook/macos'), {}); +}); + +test('approves read-only listener/process inspection and config-free loopback probes', () => { + for (const command of [ + 'ps -axo pid=,ppid=,command=', + 'ps -p 53501 -o pid=,ppid=,lstart=,command=', + 'lsof -nP -iTCP:31354 -iTCP:22154 -sTCP:LISTEN', + 'lsof -a -p 53501 -d cwd -Fn', + 'curl -q --fail --silent --max-time 8 http://127.0.0.1:38662/status', + 'curl -q -fsS http://localhost:22154/index.json', + 'curl -q --head http://127.0.0.1:31354/status', + ]) { + assert.deepEqual(shell(command), allow, command); + assert.deepEqual(shell(command, 'powershell'), {}, `PowerShell may alias ${command}`); + } + for (const command of [ + 'ps e', + 'ps -p 123 -o env', + 'lsof', + 'lsof -nP -iTCP:99999', + 'curl -fsS http://127.0.0.1:31354/status', + 'curl -q -L http://127.0.0.1:31354/status', + 'curl -q --output report.json http://127.0.0.1:31354/status', + 'curl -q -X POST http://127.0.0.1:31354/status', + 'curl -q --json data http://127.0.0.1:31354/status', + 'curl -q --config options.txt http://127.0.0.1:31354/status', + 'curl -q http://127.0.0.1:31354/session', + 'curl -q http://127.0.0.1:99999/status', + 'curl -q http://example.com:31354/status', + 'curl -q http://127.0.0.1:31354/status http://example.com/status', + 'kill -TERM 53501', + 'screencapture -x screenshot.png', + 'lldb -p 123', + 'security add-trusted-cert certificate.pem', + 'tccutil reset All', + ]) { + assert.deepEqual(shell(command), {}, command); + } +}); + +test('unrecognized or destructive operations fall through, rather than denying local approvals', () => { + for (const command of [ + 'git reset --hard', + 'git clean -fd', + 'git checkout -- AGENTS.md', + 'git push --force', + 'git commit --amend', + 'git commit --am', + 'git branch -D main', + 'git fetch --prune origin', + 'git -c alias.foo=reset foo', + 'git diff --output=/tmp/overwrite', + 'git diff --out=/tmp/overwrite', + 'git grep -Osh pattern', + 'git grep --open-files-in-pager=sh pattern', + 'git commit --a*', + 'git commit @options', + 'gh issue close 123', + 'gh run cancel 123', + 'gh auth token', + 'gh auth status --show-token', + 'gh api repos/o/r/issues -X POST', + 'gh api repos/o/r/issues --method=DELETE', + 'gh api repos/o/r/issues -f title=oops', + 'gh api repos/o/r/issues -F title=oops', + 'gh api repos/o/r/issues --input body.json', + 'gh api graphql', + 'gh api https://example.com', + 'gh api user --hostname example.com', + 'yarn install && git reset --hard', + 'yarn install; git reset --hard', + 'yarn install\nrm file', + 'yarn $(touch file)', + 'yarn `touch file`', + 'yarn > file', + 'yarn "foo; rm file"', + 'yarn "unterminated', + "yarn 'one''two'", + 'yarn # comment', + 'yarnish install', + 'node script.mjs', + ]) { + assert.deepEqual(shell(command), {}, command); + } +}); + +test('limits file and command approvals to the repository, including new paths and symlinks', () => { + const external = mkdtempSync(join(tmpdir(), 'furn-permissions-')); + const local = mkdtempSync(join(cwd, '.permission-test-')); + try { + symlinkSync(external, join(local, 'external'), 'dir'); + for (const toolName of ['view', 'create', 'edit', 'str_replace_editor']) { + assert.deepEqual(permissionDecision({ cwd, toolName, toolArgs: { path: 'new/nested/file.ts' } }), allow); + for (const path of [external, '../outside.ts', join(local, 'external/new/file.ts')]) { + assert.deepEqual(permissionDecision({ cwd, toolName, toolArgs: { path } }), {}); + } + } + assert.deepEqual(permissionDecision({ cwd: external, toolName: 'bash', toolArgs: { command: 'yarn install' } }), {}); + assert.deepEqual(shell(`git -C "${external}" status`), {}); + assert.deepEqual(shell(`git -C "${local}/external" status`), {}); + assert.deepEqual(shell(`cd "${local}/external" && yarn`), {}); + assert.deepEqual(shell(`cd "${external}" && yarn`), {}); + } finally { + rmSync(local, { recursive: true }); + rmSync(external, { recursive: true }); + } +}); + +test('checks every patch path, including move destinations', () => { + const patch = (body) => + permissionDecision({ cwd, toolName: 'apply_patch', toolArgs: { input: `*** Begin Patch\n${body}\n*** End Patch` } }); + assert.deepEqual(patch('*** Add File: new.ts\n+export {};'), allow); + assert.deepEqual(patch('*** Update File: AGENTS.md\n*** Move to: ../outside.md'), {}); + assert.deepEqual(patch('*** Add File: new.ts\n+ok\n*** Delete File: ../outside.md'), {}); + assert.deepEqual(patch(''), {}); + assert.deepEqual( + permissionDecision({ cwd, toolName: 'apply_patch', toolArgs: '*** Begin Patch\n*** Add File: new.ts\n+ok\n*** End Patch' }), + allow, + ); +}); + +test('supports serialized arguments and leaves unknown tools to normal permissions', () => { + assert.deepEqual(permissionDecision({ cwd, toolName: 'bash', toolArgs: '{"command":"yarn install"}' }), allow); + assert.deepEqual(permissionDecision({ cwd, toolName: 'unknown', toolArgs: {} }), {}); + assert.deepEqual(permissionDecision({ cwd, toolName: 'unknown', toolArgs: 'not JSON' }), {}); + assert.deepEqual(permissionDecision({ cwd, toolName: 'bash', toolArgs: {} }), {}); + assert.deepEqual(permissionDecision({ cwd: '.', toolName: 'bash', toolArgs: { command: 'yarn' } }), {}); +}); + +test('hook executable reads stdin and emits a single decision', () => { + const config = JSON.parse(readFileSync(new URL('../hooks/agent-permissions.json', import.meta.url), 'utf8')); + assert.equal(config.version, 1); + const [hook] = config.hooks.preToolUse; + assert.equal(hook.exec, 'node'); + assert.equal(hook.cwd, '.'); + for (const name of ['bash', 'powershell', 'view', 'create', 'edit', 'str_replace_editor', 'apply_patch']) { + assert.match(name, new RegExp(`^(?:${hook.matcher})$`)); + } + const result = spawnSync(process.execPath, hook.args, { + cwd, + input: JSON.stringify({ cwd, toolName: 'bash', toolArgs: { command: 'yarn install' } }), + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), allow); + const invalid = spawnSync(process.execPath, hook.args, { cwd, input: '{invalid', encoding: 'utf8' }); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /SyntaxError/); +}); diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f1423a33573..2e4e41d98b5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: yarn + - name: Test agent permissions + run: yarn test:agent-permissions + - name: Build CI run: yarn lage buildci diff --git a/.gitignore b/.gitignore index 56c3e3d4783..c89077c9ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,9 @@ apps/*/.vscode/.react/ # May contain credentials .npmrc +# Personal Copilot settings supplement the shared repository hooks. +.github/copilot/settings.local.json + # Ccache .ccache diff --git a/AGENTS.md b/AGENTS.md index 86f9b759bcb..7d8f9225862 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,75 @@ This file provides guidance to coding agents (Claude Code and others) when working with code in this repository. +## Agent permissions + +The shared Copilot CLI defaults are implemented by +[the repository hook](.github/hooks/agent-permissions.json), not by prose instructions alone. +After trusting this repository, start a new CLI session to load the hook. Node.js must be on `PATH`; +no dependency installation is needed for the hook itself. + +- Read and write files inside this repository. +- Run all Yarn commands, including `yarn` and `yarn install`. +- Inspect Git state, stage changes, create new local commits, list branches, and fetch from `origin`. + Do not amend commits, discard other people's changes, or rewrite history without explicit authorization. +- Read GitHub issues, pull requests, workflow definitions, and pipeline runs using `gh`. + REST `gh api` calls to user, repository, and search endpoints are approved only for GET requests, + without request-body/field flags. GraphQL and API mutations require separate approval. +- Inspect the active account with `gh auth status` and `gh api user --jq .login`, and use + `gh auth switch` to select an existing authenticated account when needed. Never print authentication tokens. +- Run `pod install` (including `--repo-update` and `--clean-install`) or named `pod update ...` operations, + optionally through `bundle exec`, from the owning app or its `ios`/`macos` directory. A project-directory + argument must resolve to that app's native directory and existing Podfile. Root-level Pod invocations, + updates of every pod, global cache cleanup, and `pod deintegrate` require separate approval. +- Inspect processes/listeners with constrained `ps` and `lsof` commands in Bash. Probe loopback `/status` and + `/index.json` using `curl -q` (disable user curl configuration), GET/HEAD, and the instance's reported port. + Uploads, redirects, output files, arbitrary endpoints, and process termination are not pre-approved. + +The hook accepts simple Bash/PowerShell commands and `&&` sequences when **every** command is approved. +`cd ./ && ...` is allowed only within the repository (use `./` to avoid Bash `CDPATH`). +In Bash, native-driver variables listed in the hook may prefix Yarn commands, directly for unquoted values +or through `env`, for example `FURN_NATIVE_DRIVER_TEST=1 yarn test` or +`env "FURN_DESKTOP_DRIVER_MACOS_SIGNING_IDENTITY=FURN Development" yarn storybook build-driver --macos`. +Arbitrary environment overrides, pipelines, redirections, shell expansions, unrecognized +options, remote writes, and destructive Git operations fall through to the CLI's normal permission handling; +they are not blanket-denied. Run commands separately when practical. File approvals resolve symlinks and +are limited to this repository. These defaults are **not a sandbox**: Yarn scripts and Git hooks can execute +arbitrary code, and explicit approvals intentionally trust them. + +### Native Storybook diagnostics + +The existing Yarn approval covers the supported macOS, Windows, and Win32 workflows; extra blanket approvals +for Xcode, Swift, PowerShell scripts, Metro, or screen capture are unnecessary. From `apps/storybook`: + +| Need | Preferred command | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project generation and Pod installation | `yarn storybook prep --macos` | +| Bundle failures | `yarn storybook --verbose bundle --` | +| Native build failures | `yarn storybook --verbose build --` (not Win32) | +| Driver identity, signing, and macOS privacy diagnostics | `yarn desktop-driver doctor --platform macos --permissions` | +| Actual listener ports and target identity | `yarn storybook instance --` | +| Target-scoped screenshots | `yarn desktop-driver agent screenshot --platform --url --target --artifacts artifacts/ --name ` | +| Runtime trees and crash reproduction | `yarn desktop-driver agent describe ...` and `yarn storybook --verbose smoke --` | + +For `agent` commands, use `--platform macos` on macOS and `--platform windows` for both Windows and Win32. +Inspect retained command logs under `artifacts/storybook-commands` and platform artifacts rather than redirecting +output to arbitrary locations. Screenshots require a running driver/app and the operating system's own screen +recording permission; CLI approval does not grant Accessibility or Screen Recording access. +Keep evidence scoped to the registered target and ignored artifact directories. Unrestricted `screencapture`, +`osascript`, debugger attachment, external crash-report directories, certificate trust changes, and TCC resets +remain separately authorized. Do not grant these broadly to work around a failed native capability check. + +Contributors can add permissions through the CLI's "don't ask again in this repo" prompt, which saves +repo-scoped approvals in their local `~/.copilot/permissions-config.json`, or through `--allow-tool` flags. +Additional personal hooks can be defined in the gitignored `.github/copilot/settings.local.json`. +To opt out of hooks locally, set `disableAllHooks: true` there (this disables all non-policy hooks). +Do not commit personal permission files, account names, or credentials. Hook approvals precede normal +tool permission checks; disable the hook if you need CLI deny rules to govern these approved operations. + +See the [Copilot hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference) and +[saved permissions schema](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference#permissions-configjson). +Run `yarn test:agent-permissions` after changing the policy. + ## Project Overview This is the **FluentUI React Native** repository, a monorepo containing React Native components that implement Microsoft's Fluent Design System. The repository supports multiple platforms including iOS, Android, macOS, Windows, and Win32. diff --git a/package.json b/package.json index d08d4be44af..ec743570703 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "format:check": "oxfmt --check", "lint-lockfile": "lint-lockfile", "lint-repo": "node ./scripts/src/tasks/lintRepo.ts", - "test-links": "markdown-link-check" + "test-links": "markdown-link-check", + "test:agent-permissions": "node --test .github/scripts/agent-permissions.test.mjs" }, "devDependencies": { "@babel/core": "catalog:",