blob: d1d5bc81ed7f10021a41e59b9d36bedc9c2c162c [file]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit tests for permissions definitions and expansion logic."""
from __future__ import annotations
import re
import unittest
from agents.lib import permissions
from agents_testing.base import BaseTestCase
class PermissionsTest(BaseTestCase):
"""Tests for permissions module functions."""
def setUp(self) -> None:
super().setUp()
self.mock_root = self.test_dir
def test_expand_empty_or_comment(self) -> None:
self.assertEqual(permissions.expand_command_variants(""), [])
self.assertEqual(permissions.expand_command_variants(" "), [])
self.assertEqual(
permissions.expand_command_variants("# this is a comment"), []
)
# Inline comments should be stripped
grants = permissions.expand_command_variants(
"fx test # run unit tests"
)
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
self.assertTrue(re.fullmatch(pattern, "fx test"))
def test_expand_comment_with_quoted_hash(self) -> None:
# Hash inside quotes must not be stripped as a comment
grants = permissions.expand_command_variants(
'git commit -m "fix #1234" # inline comment'
)
self.assertEqual(len(grants), 1)
pattern = grants[0][14:-1]
self.assertTrue(re.fullmatch(pattern, 'git commit -m "fix #1234"'))
def test_expand_explicit_syntax(self) -> None:
self.assertEqual(
permissions.expand_command_variants("command(foo bar)"),
["command(foo bar)"],
)
self.assertEqual(
permissions.expand_command_variants("command(regex:foo.*)"),
["command(regex:foo.*)"],
)
def test_expand_git_subcommand_regex(self) -> None:
grants = permissions.expand_command_variants("git status")
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
# Test pattern matching
self.assertTrue(re.fullmatch(pattern, "git status"))
self.assertTrue(re.fullmatch(pattern, "/usr/bin/git status"))
self.assertTrue(re.fullmatch(pattern, "git -C /some/dir status"))
self.assertTrue(
re.fullmatch(pattern, 'git -C "/dir with spaces" status')
)
self.assertTrue(re.fullmatch(pattern, "git --no-pager status"))
self.assertTrue(
re.fullmatch(pattern, 'git -c user.name="John Doe" status')
)
self.assertTrue(re.fullmatch(pattern, "FOO=bar git status"))
self.assertTrue(re.fullmatch(pattern, "git status --short"))
# Boundary checks
self.assertFalse(re.fullmatch(pattern, "git-lfs status"))
def _single_pattern(self, template: str) -> str:
grants = permissions.expand_command_variants(template)
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
return grants[0][14:-1]
def test_expand_git_bare(self) -> None:
pattern = self._single_pattern("git pull")
# Basic subcommand and path variants
self.assertTrue(re.fullmatch(pattern, "git pull"))
self.assertTrue(re.fullmatch(pattern, "/usr/bin/git pull"))
self.assertTrue(re.fullmatch(pattern, "git pull origin main"))
self.assertTrue(re.fullmatch(pattern, "/usr/bin/git pull origin main"))
self.assertFalse(re.fullmatch(pattern, "git-lfs pull"))
def test_expand_git_config_scoped(self) -> None:
cases = [
(
"git config --global",
[
"git config --global user.email 'a@b.com'",
"git -C /dir config --global -l",
],
),
(
"git config --system",
[
"git config --system user.email 'a@b.com'",
"git -C /dir config --system -l",
],
),
(
"git config --file",
[
"git config --file ~/.gitconfig user.email 'a@b.com'",
"git config --file=/etc/gitconfig user.email 'a@b.com'",
"git -C /dir config --file ~/.gitconfig -l",
],
),
(
"git config -f",
[
"git config -f ~/.gitconfig user.email 'a@b.com'",
],
),
]
for template, positive_cases in cases:
with self.subTest(template=template):
pattern = self._single_pattern(template)
for cmd in positive_cases:
self.assertTrue(re.fullmatch(pattern, cmd), cmd)
self.assertFalse(
re.fullmatch(pattern, "git config user.email 'a@b.com'")
)
self.assertFalse(
re.fullmatch(pattern, "git config --get user.email")
)
def test_expand_git_force_push_matching(self) -> None:
pattern = self._single_pattern("git push --force")
self.assertTrue(re.fullmatch(pattern, "git push --force"))
self.assertTrue(re.fullmatch(pattern, "git push origin main --force"))
self.assertTrue(
re.fullmatch(pattern, 'git push "my branch with spaces" --force')
)
self.assertTrue(
re.fullmatch(pattern, "git -C /dir push origin HEAD --force")
)
self.assertFalse(re.fullmatch(pattern, "git push origin main"))
# Test --force-with-lease matching including =<ref> syntax
lease_pattern = self._single_pattern("git push --force-with-lease")
for cmd in (
"git push --force-with-lease",
"git push origin main --force-with-lease",
"git push origin main --force-with-lease=main",
"git -C /dir push -u origin HEAD --force-with-lease=refs/heads/main:refs/heads/main",
):
self.assertTrue(re.fullmatch(lease_pattern, cmd), cmd)
self.assertFalse(re.fullmatch(lease_pattern, "git push origin main"))
def test_expand_git_clean_force(self) -> None:
pattern = self._single_pattern("git clean -f")
# All flag variations and permutations must match
for cmd in (
"git clean -f",
"git clean -fd",
"git clean -df",
"git clean -fdx",
"git clean -fxd",
"git clean -dfx",
"git clean -dxf",
"git clean -xfd",
"git clean -xdf",
"git clean -fX",
"git clean -Xf",
"git clean --force",
"git clean --force=true",
"git clean -d -x -f",
"git -C /dir clean -fxd",
"git --git-dir=/dir clean -fxd",
):
self.assertTrue(re.fullmatch(pattern, cmd), cmd)
# Non-destructive clean commands must NOT match
for cmd in (
"git clean",
"git clean -n",
"git clean -nd",
"git clean -n -d -x",
"git clean -i",
"git clean --dry-run",
):
self.assertFalse(re.fullmatch(pattern, cmd), cmd)
def test_expand_git_quoted_arguments(self) -> None:
grants = permissions.expand_command_variants(
'git commit -m "fix -f bug"'
)
self.assertTrue(len(grants) >= 1)
patterns = [g[14:-1] for g in grants if g.startswith("command(regex:")]
self.assertTrue(
any(re.fullmatch(p, 'git commit -m "fix -f bug"') for p in patterns)
)
def test_expand_quoted_binary_path(self) -> None:
grants = permissions.expand_command_variants(
'"/custom/path to/git" status'
)
self.assertTrue(len(grants) >= 1)
self.assertTrue(any("git" in g and "status" in g for g in grants))
def test_expand_fx_variants(self) -> None:
grants = permissions.expand_command_variants("fx test")
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
# Positive matching
self.assertTrue(re.fullmatch(pattern, "fx test"))
self.assertTrue(re.fullmatch(pattern, "fx -t target-name test"))
self.assertTrue(
re.fullmatch(pattern, "fx -t target-name test //foo:bar")
)
self.assertTrue(
re.fullmatch(pattern, "fx -t [fe80::1%eth0]:8022 test //foo:bar")
)
self.assertTrue(re.fullmatch(pattern, 'fx -t="my-device" test'))
self.assertTrue(re.fullmatch(pattern, "fx --dir out/default test"))
self.assertTrue(
re.fullmatch(
pattern,
"fx --dir=out/default -t my-dev -i -x test //pkg:test",
)
)
self.assertTrue(re.fullmatch(pattern, "scripts/fx -t my-device test"))
self.assertTrue(
re.fullmatch(pattern, "./scripts/fx --enable=incremental test")
)
self.assertTrue(re.fullmatch(pattern, "tools/fx test"))
self.assertTrue(re.fullmatch(pattern, ".jiri_root/bin/fx test"))
self.assertTrue(re.fullmatch(pattern, "ENV_VAR=1 fx -t my-dev test"))
# Negative matching
self.assertFalse(re.fullmatch(pattern, "fx build"))
self.assertFalse(re.fullmatch(pattern, "fx testing"))
self.assertFalse(re.fullmatch(pattern, "fxx test"))
def test_expand_fx_empty_args(self) -> None:
grants = permissions.expand_command_variants("fx")
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
self.assertTrue(re.fullmatch(pattern, "fx"))
self.assertTrue(re.fullmatch(pattern, "fx -x"))
self.assertTrue(re.fullmatch(pattern, "scripts/fx -t my-device build"))
def test_expand_ffx_variants(self) -> None:
grants = permissions.expand_command_variants("ffx target list")
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
# Positive matching
self.assertTrue(re.fullmatch(pattern, "ffx target list"))
self.assertTrue(re.fullmatch(pattern, "ffx -t my-device target list"))
self.assertTrue(re.fullmatch(pattern, "ffx --machine json target list"))
self.assertTrue(
re.fullmatch(
pattern,
"ffx -c log.level=Debug -t my-device --machine json target list",
)
)
self.assertTrue(re.fullmatch(pattern, "ffx -v --strict target list"))
self.assertTrue(re.fullmatch(pattern, "fx ffx target list"))
self.assertTrue(
re.fullmatch(pattern, "fx -t my-dev ffx --machine json target list")
)
self.assertTrue(
re.fullmatch(
pattern, "scripts/fx -t my-dev ffx -t my-dev2 target list"
)
)
self.assertTrue(
re.fullmatch(pattern, "ENV_VAR=1 ffx -t dev target list")
)
# Negative matching
self.assertFalse(re.fullmatch(pattern, "ffx target show"))
self.assertFalse(re.fullmatch(pattern, "ffx emu start"))
def test_expand_ffx_empty_args(self) -> None:
grants = permissions.expand_command_variants("ffx")
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
self.assertTrue(re.fullmatch(pattern, "ffx"))
self.assertTrue(re.fullmatch(pattern, "ffx -v"))
self.assertTrue(re.fullmatch(pattern, "fx ffx target list"))
self.assertTrue(
re.fullmatch(pattern, "scripts/fx -t my-dev ffx target list")
)
def test_expand_jiri_variants(self) -> None:
grants = permissions.expand_command_variants("jiri status")
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
# Positive matching
self.assertTrue(re.fullmatch(pattern, "jiri status"))
self.assertTrue(re.fullmatch(pattern, "jiri -v status"))
self.assertTrue(re.fullmatch(pattern, "jiri -vv status"))
self.assertTrue(re.fullmatch(pattern, "jiri -j 50 status"))
self.assertTrue(
re.fullmatch(
pattern, "jiri -root /path/to/root -color never status"
)
)
self.assertTrue(
re.fullmatch(
pattern, ".jiri_root/bin/jiri -show-progress=false status"
)
)
self.assertTrue(re.fullmatch(pattern, "ENV_VAR=1 jiri -q status"))
self.assertTrue(re.fullmatch(pattern, "jiri -time status"))
self.assertTrue(re.fullmatch(pattern, "jiri --time status"))
self.assertTrue(re.fullmatch(pattern, "jiri --quiet status"))
self.assertTrue(re.fullmatch(pattern, "jiri --show-progress status"))
self.assertTrue(
re.fullmatch(pattern, "jiri -time-log-threshold 5s status")
)
self.assertTrue(
re.fullmatch(pattern, "jiri -timefile /tmp/timing.txt status")
)
self.assertTrue(
re.fullmatch(pattern, "jiri -progress-window 10 status")
)
# Negative matching
self.assertFalse(re.fullmatch(pattern, "jiri update"))
self.assertFalse(re.fullmatch(pattern, "jiri snapshot"))
def test_expand_jiri_empty_args(self) -> None:
grants = permissions.expand_command_variants("jiri")
self.assertEqual(len(grants), 1)
self.assertTrue(grants[0].startswith("command(regex:"))
pattern = grants[0][14:-1]
self.assertTrue(re.fullmatch(pattern, "jiri"))
self.assertTrue(re.fullmatch(pattern, "jiri -v"))
self.assertTrue(re.fullmatch(pattern, "jiri -q status"))
self.assertTrue(
re.fullmatch(pattern, ".jiri_root/bin/jiri -j 10 update")
)
def test_tool_spec_custom_tool(self) -> None:
spec = permissions.ToolSpec(
binary_prefix=r"(\S+/)?custom",
global_flags_pattern=r"(\s+--flag)*",
allow_flags_anywhere=True,
)
grants = spec.expand("build --opt")
self.assertEqual(len(grants), 1)
pattern = grants[0][14:-1]
self.assertTrue(re.fullmatch(pattern, "custom --flag build --opt"))
self.assertTrue(
re.fullmatch(pattern, "/path/custom build file.txt --opt")
)
def test_expand_python_interpreter_variants(self) -> None:
py_grants = permissions.expand_command_variants("python3 script.py")
self.assertIn("command(python script.py)", py_grants)
self.assertIn("command(python3 script.py)", py_grants)
self.assertIn("command(fuchsia-vendored-python script.py)", py_grants)
self.assertIn(
"command(scripts/fuchsia-vendored-python script.py)", py_grants
)
def test_expand_sed_inplace_regex(self) -> None:
pattern = self._single_pattern("sed -i 's/foo/bar/g'")
self.assertTrue(re.fullmatch(pattern, "sed -i 's/foo/bar/g' file.txt"))
self.assertTrue(re.fullmatch(pattern, "/usr/bin/sed -i.bak 's/a/b/' f"))
self.assertTrue(re.fullmatch(pattern, "sed -E -i '' 's/a/b/' f"))
self.assertTrue(
re.fullmatch(pattern, "sed --in-place=suffix 's/a/b/' file.txt")
)
# Read-only stream commands containing '-i' in expressions must not trigger in-place regex
readonly_pattern = self._single_pattern(
"sed 's/foo -i bar/baz/' file.txt"
)
self.assertTrue(
re.fullmatch(readonly_pattern, "sed 's/foo -i bar/baz/' file.txt")
)
self.assertTrue(
re.fullmatch(
readonly_pattern, "/usr/bin/sed 's/foo -i bar/baz/' file.txt"
)
)
self.assertFalse(re.fullmatch(readonly_pattern, "sed -i file.txt"))
def test_expand_system_binary_regex(self) -> None:
# Bare command without arguments
cat_pattern = self._single_pattern("cat")
for cmd in (
"cat",
"cat file.txt",
"/bin/cat file.txt",
"/usr/bin/cat file.txt",
"LC_ALL=C cat file.txt",
):
self.assertTrue(re.fullmatch(cat_pattern, cmd), cmd)
for cmd in ("catalog file.txt", "cat_file"):
self.assertFalse(re.fullmatch(cat_pattern, cmd), cmd)
# Commands with arguments and trailing path wildcards
cases = [
(
"ls -la",
[
"ls -la",
"ls -la /some/dir",
"/bin/ls -la",
"/usr/bin/ls -la",
"FOO=bar ls -la",
],
[
"ls -l",
"ls",
],
),
(
"rm -rf /",
[
"rm -rf /",
"rm -rf //",
"/bin/rm -rf /",
"rm -rf / home",
],
[
"rm -rf /var",
"rm -rf /tmp/foo",
"rm -rf",
],
),
(
"rm -rf ~",
[
"rm -rf ~",
"rm -rf ~/",
"rm -rf ~//",
"rm -rf ~ home",
],
[
"rm -rf ~/dir",
"rm -rf ~other",
"rm -rf ~/fuchsia/out",
],
),
]
for template, positive_cases, negative_cases in cases:
with self.subTest(template=template):
pattern = self._single_pattern(template)
for cmd in positive_cases:
self.assertTrue(re.fullmatch(pattern, cmd), cmd)
for cmd in negative_cases:
self.assertFalse(re.fullmatch(pattern, cmd), cmd)
def test_expand_trusted_help_variants(self) -> None:
# ToolSpec tool (ffx) with global flags and subcommands
pattern = self._single_pattern("ffx help")
for cmd in (
"ffx --help",
"ffx help target",
"ffx target --help",
"ffx component list --help",
"ffx -t dev target --help",
):
self.assertTrue(re.fullmatch(pattern, cmd), cmd)
for cmd in (
"ffx target",
"ffx target reboot",
"ffx target reboot now",
):
self.assertFalse(re.fullmatch(pattern, cmd), cmd)
# Non-ToolSpec tool (cipd)
cipd_pattern = self._single_pattern("cipd help")
self.assertTrue(re.fullmatch(cipd_pattern, "cipd --help"))
self.assertTrue(re.fullmatch(cipd_pattern, "cipd help ensure"))
self.assertTrue(re.fullmatch(cipd_pattern, "cipd ensure --help"))
self.assertFalse(re.fullmatch(cipd_pattern, "cipd ensure"))
# Untrusted tools do not get help expansion
echo_pattern = self._single_pattern("echo help")
self.assertTrue(re.fullmatch(echo_pattern, "echo help"))
self.assertFalse(re.fullmatch(echo_pattern, "echo --help"))
def test_read_command_list_file(self) -> None:
list_file = self.mock_root / "commands.txt"
list_file.write_text(
"# A comment\n"
"git status\n"
"\n"
"command(explicit_rule)\n"
"fx build\n",
encoding="utf-8",
)
grants = permissions.read_command_list_file(list_file)
self.assertTrue(
any(
g.startswith("command(regex:") and "git" in g and "status" in g
for g in grants
)
)
self.assertIn("command(explicit_rule)", grants)
self.assertTrue(
any(
g.startswith("command(regex:") and "fx" in g and "build" in g
for g in grants
)
)
def test_read_command_list_file_nonexistent(self) -> None:
self.assertEqual(
permissions.read_command_list_file(
self.mock_root / "nonexistent.txt"
),
[],
)
def test_load_profile_grants(self) -> None:
fuchsia_dir = self.mock_root
perm_dir = fuchsia_dir / ".agents" / "config" / "permissions"
perm_dir.mkdir(parents=True, exist_ok=True)
(perm_dir / "read_only.txt").write_text(
"git status\nfx status\n", encoding="utf-8"
)
(perm_dir / "local_changes.txt").write_text(
"git commit\n", encoding="utf-8"
)
(perm_dir / "external_changes.txt").write_text(
"git push\n", encoding="utf-8"
)
(perm_dir / "never_allow.txt").write_text(
"git reset --hard\n", encoding="utf-8"
)
(perm_dir / "device_ops.txt").write_text("fx ota\n", encoding="utf-8")
(perm_dir / "cache_destruction.txt").write_text(
"fx clean\n", encoding="utf-8"
)
(perm_dir / "batch_execution.txt").write_text(
"find\n", encoding="utf-8"
)
grants = permissions.load_profile_grants(fuchsia_dir, "read-only")
self.assertTrue(any("git" in g and "status" in g for g in grants.allow))
self.assertTrue(any("git" in g and "reset" in g for g in grants.deny))
self.assertFalse(any("git" in g and "commit" in g for g in grants.deny))
self.assertFalse(
any("git" in g and "commit" in g for g in grants.allow)
)
self.assertTrue(any("git" in g and "push" in g for g in grants.ask))
self.assertTrue(any("fx" in g and "ota" in g for g in grants.ask))
self.assertTrue(any("find" in g for g in grants.ask))
grants_local = permissions.load_profile_grants(
fuchsia_dir, "local-changes"
)
self.assertTrue(
any("git" in g and "commit" in g for g in grants_local.allow)
)
self.assertTrue(
any("git" in g and "push" in g for g in grants_local.ask)
)
self.assertTrue(
any("git" in g and "reset" in g for g in grants_local.deny)
)
self.assertTrue(any("find" in g for g in grants_local.ask))
grants_ext = permissions.load_profile_grants(
fuchsia_dir, "external-changes"
)
self.assertTrue(
any("git" in g and "push" in g for g in grants_ext.allow)
)
self.assertTrue(any("find" in g for g in grants_ext.ask))
if __name__ == "__main__":
unittest.main()