diff --git a/ChangeLog b/ChangeLog index 19d5d4b8..b9c455d5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,11 @@ +*setools-4.7.1 (28 Jul 2026) + + * Add initial prompts to MCP server. + * Add file_contexts querying class. + * Change MCP server to use standalone FastMCP package. The FastMCP server in + the mcp package is a legacy version. FastMCP depends on the mcp pacage, so + this change is not expected to be problematic for users or distros. + *setools-4.7.0 (23 Jun 2026) * Add MCP server to provide LLMs the ability to analyze policy. diff --git a/README.md b/README.md index ea83f22a..0623a450 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ To run SETools graphical tools, the following packages are also required: To run SETools AI tools, the following packages are also required: -* mcp +* FastMCP 2.0+ To build SETools, the following development packages are required, in addition to the development packages from the above list: diff --git a/pyproject.toml b/pyproject.toml index 29649f16..1abcbc46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "setools" -version = "4.7.0" +version = "4.7.1" description="SELinux policy analysis tools." authors = [{name = "Chris PeBenito", email="pebenito@ieee.org"}] license = "LGPL-2.1-only AND GPL-2.0-only" @@ -45,7 +45,7 @@ dependencies = [] optional-dependencies.analysis = ["networkx>=2.6", "pygraphviz"] optional-dependencies.gui = ["PyQt6"] -optional-dependencies.mcp = ["mcp>=1.0"] +optional-dependencies.mcp = ["fastmcp>=2.0"] optional-dependencies.test = ["tox"] [tool.setuptools] diff --git a/setools-mcp b/setools-mcp index 110a59bd..6b098cf1 100755 --- a/setools-mcp +++ b/setools-mcp @@ -10,12 +10,12 @@ import warnings parser = argparse.ArgumentParser( description="SETools MCP server — exposes setools policy analysis as MCP tools.") parser.add_argument("--policy", metavar="PATH", help="Default SELinux policy file to analyze.") -parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio", - help="MCP transport to use (default: stdio).") +parser.add_argument("--transport", choices=["stdio", "http"], default="stdio", + help="MCP transport to use (stdio, http, default: stdio).") parser.add_argument("--host", default="127.0.0.1", - help="Bind host for SSE transport (default: 127.0.0.1).") + help="Bind host for HTTP transport (default: 127.0.0.1).") parser.add_argument("--port", type=int, default=8000, - help="Bind port for SSE transport (default: 8000).") + help="Bind port for HTTP transport (default: 8000).") parser.add_argument("-v", "--verbose", action="store_true", help="Print extra informational messages") parser.add_argument("--debug", action="store_true", dest="debug", help="Enable debugging.") diff --git a/setools/__init__.py b/setools/__init__.py index 321440f7..5d330e48 100644 --- a/setools/__init__.py +++ b/setools/__init__.py @@ -15,8 +15,9 @@ # Python classes for policy representation from .policyrep import SELinuxPolicy, BoundsRuletype, ConstraintRuletype, DefaultRuletype, \ - DefaultRangeValue, DefaultValue, FSUseRuletype, HandleUnknown, IbpkeyconRange, MLSRuletype, \ - NodeconIPVersion, PolicyTarget, PortconProtocol, RBACRuletype, TERuletype + DefaultRangeValue, DefaultValue, FileContexts, FileContextsFiletype, FSUseRuletype, \ + HandleUnknown, IbpkeyconRange, MLSRuletype, NodeconIPVersion, PolicyTarget, PortconProtocol, \ + RBACRuletype, TERuletype # Policy representation classes for type checking purposes. Few can be instantiated # outside of this library. diff --git a/setools/exception.py b/setools/exception.py index 434e169e..21f16977 100644 --- a/setools/exception.py +++ b/setools/exception.py @@ -267,6 +267,12 @@ class InvalidDefaultRange(InvalidSymbol): # # Other exceptions # +class InvalidContext(ValueError, PolicyrepException): + + """Exception for invalid contexts.""" + pass + + class NoCommon(AttributeError, PolicyrepException): """ @@ -281,6 +287,12 @@ class NoDefaults(InvalidSymbol): pass +class NoFileContextsMatch(ValueError): + + """Exception when a path does not match any file context entries.""" + pass + + class RuleNotConditional(AttributeError, PolicyrepException): """ diff --git a/setools/mcp/server.py b/setools/mcp/server.py index 5e8b4961..7dcd7e00 100644 --- a/setools/mcp/server.py +++ b/setools/mcp/server.py @@ -10,13 +10,14 @@ from typing import Annotated, Any, Final, Literal try: - from mcp.server.fastmcp import FastMCP + from fastmcp import FastMCP except ImportError as iex: logging.getLogger(__name__).debug(f"{iex.name} failed to import.") from .. import (BoolQuery, BoundsQuery, BoundsRuletype, CategoryQuery, CommonQuery, ConstraintQuery, ConstraintRuletype, DefaultQuery, DefaultRuletype, - DevicetreeconQuery, DomainTransitionAnalysis, FSUseQuery, FSUseRuletype, + DevicetreeconQuery, DomainTransitionAnalysis, FileContexts, + FileContextsFiletype, FSUseQuery, FSUseRuletype, GenfsconQuery, IbendportconQuery, IbpkeyconQuery, IbpkeyconRange, InfoFlowAnalysis, InitialSIDQuery, IomemconQuery, IomemconRange, IoportconQuery, IoportconRange, MLSRuleQuery, MLSRuletype, NetifconQuery, @@ -29,7 +30,8 @@ __all__ = ("MCPEncoder",) -TOOL_PREFIX: Final[str] = "setools_" +TOOL_PREFIX: Final[str] = "setools_tool_" +PROMPT_PREFIX: Final[str] = "setools_prompt_" class DiffComponent(str, enum.Enum): @@ -52,6 +54,13 @@ def __missing__(self, key: str | None) -> SELinuxPolicy: return self[key] +class FileContextsCache(dict): + """Simple cache for loaded file_contexts""" + def __missing__(self, key: str | None) -> FileContexts: + self[key] = FileContexts(None, key) + return self[key] + + class SEToolsMCPServer: """ MCP server encapsulating all setools policy analysis tools. @@ -64,6 +73,7 @@ def __init__(self, default_policy: str | None = None) -> None: self.log: logging.Logger = logging.getLogger(__name__) self.default_policy: str | None = default_policy self._policy_cache: PolicyCache = PolicyCache() + self._fc_cache: FileContextsCache = FileContextsCache() try: # Init the policy cache. Load the default policy as the None key and its path. @@ -79,21 +89,28 @@ def __init__(self, default_policy: str | None = None) -> None: instructions=( "SELinux policy analysis tools built on the setools library. " "Supports querying TE/RBAC/MLS rules, enumerating policy components, " - "domain transition analysis, information flow analysis, and policy diffing." + "domain transition analysis, information flow analysis, policy diffing," + "and file_context lookup." ), ) for name in dir(self): if name.startswith(TOOL_PREFIX) and callable(getattr(self, name)): + self.log.info(f"Registering tool: {name}") self.mcp.tool()(getattr(self, name)) + elif name.startswith(PROMPT_PREFIX) and callable(getattr(self, name)): + self.log.info(f"Registering prompt: {name}") + self.mcp.prompt()(getattr(self, name)) - def run(self, transport: Literal["stdio", "sse", "streamable-http"] = "stdio", + def run(self, transport: Literal["stdio", "http"] = "stdio", host: str = "127.0.0.1", port: int = 8000) -> None: """Start the MCP server with the given transport.""" - if transport == "sse": - self.mcp.settings.host = host - self.mcp.settings.port = port - self.mcp.run(transport=transport) + if transport == "http": + self.log.info(f"Running MCP server over HTTP at {host}:{port}") + self.mcp.run(transport=transport, host=host, port=port) + else: + self.log.info("Running MCP server over stdio") + self.mcp.run(transport=transport) # # Helpers @@ -112,6 +129,13 @@ def _collect_results(query: PolicyQuery, *, max_results: int = 32768) -> str: returned_count, truncated) + def _load_file_contexts(self, fc_path: str | None = None) -> FileContexts: + """ + Return a (cached) FileContexts for file_contexts at path *fc_path*. + If *fc_path* is None, uses the system default file_contexts. + """ + return self._fc_cache[fc_path] + def _load_policy(self, policy: str | None = None) -> SELinuxPolicy: """ Return a (cached) SELinuxPolicy for policy at path *policy*. @@ -128,10 +152,49 @@ def _serialize_results(result: Any, count: int, truncated: bool) -> str: cls=MCPEncoder, indent=2) + # + # Prompts + # + def setools_prompt_summarize( + self, + name: Annotated[str, "Domain (type) or program to summarize access for."], + ) -> str: + """Summarize the access of a domain (type) or program.""" + return \ + f"Summarize the access of {name}. If this cannot be resolved to a type" \ + "attempt to look up the executable type in file_contexts and if this is an" \ + "entrypoint type, summarize the access of the related domain." + + def setools_prompt_cve( + self, + cve: Annotated[str, "CVE identifier, e.g., CVE-2026-31431"], + ) -> str: + """Find domains might be vulnerable to a CVE.""" + return \ + "Based on the attached SELinux policy, what programs may be able to exploit " \ + f"{cve}. Do not speculate." + + def setools_prompt_dta_out( + self, + name: Annotated[str, "Domain (type) to summarize transitions for."], + ) -> str: + """List the transitions out of a domain (type).""" + return \ + f"List the direct transitions out of {name}." + + def setools_prompt_dta_in( + self, + name: Annotated[str, "Domain (type) to summarize transitions for."], + ) -> str: + """List the transitions into a domain (type).""" + return \ + f"List the direct transitions into {name}." + # # MCP Tools # - def setools_get_policy_info( + + def setools_tool_get_policy_info( self, policy_path: Annotated[ str | None, @@ -142,7 +205,7 @@ def setools_get_policy_info( """Return statistics and metadata about an SELinux policy.""" return self._serialize_results(self._load_policy(policy_path), 1, False) - def setools_search_te_rules( + def setools_tool_search_te_rules( self, ruletypes: Annotated[ list[str] | None, @@ -212,7 +275,7 @@ def setools_search_te_rules( return self._collect_results(q, max_results=max_results) - def setools_search_rbac_rules( + def setools_tool_search_rbac_rules( self, ruletypes: Annotated[ list[str] | None, @@ -254,7 +317,7 @@ def setools_search_rbac_rules( return self._collect_results(q, max_results=max_results) - def setools_search_mls_rules( + def setools_tool_search_mls_rules( self, ruletypes: Annotated[ list[str] | None, @@ -283,7 +346,7 @@ def setools_search_mls_rules( return self._collect_results(q, max_results=max_results) - def setools_list_types( + def setools_tool_list_types( self, name: Annotated[str | None, "Type name (or regex pattern) to filter by. "] = None, name_regex: Annotated[bool, "Treat name as a regular expression."] = False, @@ -315,7 +378,7 @@ def setools_list_types( return self._collect_results(q, max_results=max_results) - def setools_list_type_attributes( + def setools_tool_list_type_attributes( self, name: Annotated[str | None, "Attribute name (or regex pattern) to filter by."] = None, name_regex: Annotated[bool, "Treat name as a regular expression."] = False, @@ -334,7 +397,7 @@ def setools_list_type_attributes( return self._collect_results(q, max_results=max_results) - def setools_list_roles( + def setools_tool_list_roles( self, name: Annotated[str | None, "Role name (or regex pattern) to filter by."] = None, name_regex: Annotated[bool, "Treat name as a regular expression."] = False, @@ -353,7 +416,7 @@ def setools_list_roles( return self._collect_results(q, max_results=max_results) - def setools_list_role_types( + def setools_tool_list_role_types( self, type_name: Annotated[str, "Type name (or regex pattern) to find associated roles for."], type_regex: Annotated[bool, "Treat type_name as a regular expression."] = False, @@ -372,7 +435,7 @@ def setools_list_role_types( return self._collect_results(q, max_results=max_results) - def setools_list_users( + def setools_tool_list_users( self, name: Annotated[ str | None, "User name (or regex pattern) to filter by." @@ -393,7 +456,7 @@ def setools_list_users( return self._collect_results(q, max_results=max_results) - def setools_list_classes( + def setools_tool_list_classes( self, name: Annotated[ str | None, "Class name (or regex pattern) to filter by." @@ -414,7 +477,7 @@ def setools_list_classes( return self._collect_results(q, max_results=max_results) - def setools_list_commons( + def setools_tool_list_commons( self, name: Annotated[ str | None, "Common name (or regex pattern) to filter by." @@ -430,7 +493,7 @@ def setools_list_commons( return self._collect_results(q, max_results=max_results) - def setools_list_booleans( + def setools_tool_list_booleans( self, name: Annotated[ str | None, "Boolean name (or regex pattern) to filter by." @@ -453,7 +516,7 @@ def setools_list_booleans( return self._collect_results(q, max_results=max_results) - def setools_list_sensitivities( + def setools_tool_list_sensitivities( self, name: Annotated[ str | None, "Sensitivity name (or regex pattern) to filter by." @@ -476,7 +539,7 @@ def setools_list_sensitivities( return self._collect_results(q, max_results=max_results) - def setools_list_categories( + def setools_tool_list_categories( self, name: Annotated[ str | None, "Category name (or regex pattern) to filter by." @@ -499,7 +562,7 @@ def setools_list_categories( return self._collect_results(q, max_results=max_results) - def setools_list_polcaps( + def setools_tool_list_polcaps( self, name: Annotated[ str | None, "Policy capability name (or regex pattern) to filter by." @@ -515,7 +578,7 @@ def setools_list_polcaps( return self._collect_results(q, max_results=max_results) - def setools_list_permissive_types( + def setools_tool_list_permissive_types( self, name: Annotated[ str | None, "Type name (or regex pattern) to filter by." @@ -533,7 +596,7 @@ def setools_list_permissive_types( return self._collect_results(q, max_results=max_results) - def setools_list_typebounds( + def setools_tool_list_typebounds( self, child: Annotated[ str | None, "Bound (child) type name (or regex pattern) to filter by." @@ -561,7 +624,7 @@ def setools_list_typebounds( return self._collect_results(q, max_results=max_results) - def setools_list_constraints( + def setools_tool_list_constraints( self, tclass: Annotated[ list[str] | None, @@ -590,7 +653,7 @@ def setools_list_constraints( return self._collect_results(q, max_results=max_results) - def setools_list_defaults( + def setools_tool_list_defaults( self, tclass: Annotated[ list[str] | None, "Object class(es) to filter by." @@ -613,7 +676,7 @@ def setools_list_defaults( return self._collect_results(q, max_results=max_results) - def setools_list_fs_uses( + def setools_tool_list_fs_uses( self, fs: Annotated[ str | None, "Filesystem type name (or regex pattern) to filter by." @@ -636,7 +699,7 @@ def setools_list_fs_uses( return self._collect_results(q, max_results=max_results) - def setools_list_genfscons( + def setools_tool_list_genfscons( self, fs: Annotated[ str | None, "Filesystem type name (or regex pattern) to filter by." @@ -658,7 +721,7 @@ def setools_list_genfscons( return self._collect_results(q, max_results=max_results) - def setools_list_initialsids( + def setools_tool_list_initialsids( self, name: Annotated[ str | None, "Initial SID name (or regex pattern) to filter by." @@ -674,7 +737,7 @@ def setools_list_initialsids( return self._collect_results(q, max_results=max_results) - def setools_list_portcons( + def setools_tool_list_portcons( self, ports: Annotated[ str | None, @@ -710,7 +773,7 @@ def setools_list_portcons( return self._collect_results(q, max_results=max_results) - def setools_list_netifcons( + def setools_tool_list_netifcons( self, name: Annotated[ str | None, "Network interface name (or regex pattern) to filter by." @@ -726,7 +789,7 @@ def setools_list_netifcons( return self._collect_results(q, max_results=max_results) - def setools_list_nodecons( + def setools_tool_list_nodecons( self, network: Annotated[ str | None, @@ -747,7 +810,7 @@ def setools_list_nodecons( return self._collect_results(q, max_results=max_results) - def setools_list_ibpkeycons( + def setools_tool_list_ibpkeycons( self, pkeys: Annotated[ str | None, @@ -779,7 +842,7 @@ def setools_list_ibpkeycons( return self._collect_results(q, max_results=max_results) - def setools_list_ibendportcons( + def setools_tool_list_ibendportcons( self, name: Annotated[ str | None, "Infiniband device name (or regex pattern) to filter by." @@ -797,7 +860,7 @@ def setools_list_ibendportcons( return self._collect_results(q, max_results=max_results) - def setools_list_iomemcons( + def setools_tool_list_iomemcons( self, addr: Annotated[ str | None, @@ -824,7 +887,7 @@ def setools_list_iomemcons( return self._collect_results(q, max_results=max_results) - def setools_list_ioportcons( + def setools_tool_list_ioportcons( self, ports: Annotated[ str | None, @@ -852,7 +915,7 @@ def setools_list_ioportcons( return self._collect_results(q, max_results=max_results) - def setools_list_pcidevicecons( + def setools_tool_list_pcidevicecons( self, device: Annotated[ str | None, "PCI device address in hex to filter by, e.g. '0xc800'." @@ -866,7 +929,7 @@ def setools_list_pcidevicecons( return self._collect_results(q, max_results=max_results) - def setools_list_pirqcons( + def setools_tool_list_pirqcons( self, irq: Annotated[int | None, "IRQ number to filter by."] = None, max_results: Annotated[int, "Maximum number of results."] = 200, @@ -878,7 +941,7 @@ def setools_list_pirqcons( return self._collect_results(q, max_results=max_results) - def setools_list_devicetreecons( + def setools_tool_list_devicetreecons( self, path: Annotated[ str | None, "Device tree path (or regex pattern) to filter by." @@ -894,7 +957,7 @@ def setools_list_devicetreecons( return self._collect_results(q, max_results=max_results) - def setools_analyze_dta( + def setools_tool_analyze_dta( self, mode: Annotated[ str, @@ -977,7 +1040,7 @@ def setools_analyze_dta( return self._serialize_results(results, len(results), truncated) - def setools_analyze_info_flow( + def setools_tool_analyze_info_flow( self, mode: Annotated[ str, @@ -1062,7 +1125,7 @@ def setools_analyze_info_flow( return self._serialize_results(results, len(results), truncated) - def setools_diff_policies( + def setools_tool_diff_policies( self, left_policy: Annotated[str, "Path to the left (baseline) policy file."], right_policy: Annotated[str, "Path to the right (new) policy file."], @@ -1223,3 +1286,31 @@ def _cap(items: Any, limit: int) -> tuple[list[Any], bool]: } return self._serialize_results(differences, count, any_truncated) + + def setools_tool_lookup_file_context( + self, + path: Annotated[str, "The file path to look up in the file_contexts."], + filetype: Annotated[ + str | None, + "File type to match. Valid values: any, file, dir, chr_file, blk_file, " + "sock_file, fifo_file, lnk_file. If omitted, defaults to 'any'.", + ] = None, + fc_path: Annotated[ + str | None, + "Path to a file_contexts file. If omitted, uses the system default.", + ] = None, + ) -> str: + """ + Look up the SELinux file context for a given path. + + Returns the security context that would be assigned to the specified + path according to the file_contexts configuration. + + Use this instead of a naive text search, as it properly evaluates the + file context rules, including regex precedence and file type specifiers. + """ + ft = FileContextsFiletype[filetype] if filetype else FileContextsFiletype.any + + fc = self._load_file_contexts(fc_path) + result = fc.lookup(path, ft) + return self._serialize_results(result, 1, False) diff --git a/setools/policyrep.pyi b/setools/policyrep.pyi index e44b7faa..6f66878e 100644 --- a/setools/policyrep.pyi +++ b/setools/policyrep.pyi @@ -199,6 +199,22 @@ class DefaultValue(PolicyEnum): class Devicetreecon(Ocontext): path: str = ... +class FileContexts: + policy: "SELinuxPolicy | None" = ... + path: str = ... + def __init__(self, policy: "SELinuxPolicy | None", fc_path: str | None = None) -> None: ... + def lookup(self, path: str, filetype: FileContextsFiletype = FileContextsFiletype.any) -> "Context | str": ... + +class FileContextsFiletype(PolicyEnum): + any = ... + dir = ... + chr_file = ... + blk_file = ... + sock_file = ... + fifo_file = ... + lnk_file = ... + file = ... + class FSUse(Ocontext): fs: str = ... ruletype: "FSUseRuletype" = ... @@ -342,9 +358,13 @@ class Range(PolicyObject): high: Level = ... low: Level = ... def __contains__(self, other) -> bool: ... + def __xor__(self, other) -> bool: ... + def dom(self, other) -> bool: ... + def domby(self, other) -> bool: ... class Role(PolicySymbol): dominated_roles: frozenset["Role"] = ... + def __contains__(self, other) -> bool: ... def expand(self) -> Iterable["Role"]: ... def types(self) -> Iterable["Type"]: ... @@ -438,6 +458,7 @@ class SELinuxPolicy: def lookup_category(self, name: "Category" | str, deref: bool = True) -> "Category": ... def lookup_class(self, name: "ObjClass" | str) -> "ObjClass": ... def lookup_common(self, name: "Common" | str) -> "Common": ... + def lookup_context(self, ctx: "Context" | str) -> "Context": ... def lookup_initialsid(self, name: "InitialSID" | str) -> "InitialSID": ... def lookup_level(self, name: "Level" | str) -> "Level": ... def lookup_range(self, name: "Range" | str) -> "Range": ... @@ -509,6 +530,7 @@ class User(PolicySymbol): mls_level: "Level" = ... mls_range: "Range" = ... roles: frozenset["Role"] = ... + def __contains__(self, other) -> bool: ... class Validatetrans(BaseConstraint): perms: NoReturn = ... diff --git a/setools/policyrep.pyx b/setools/policyrep.pyx index b1be4d37..9e2d6762 100644 --- a/setools/policyrep.pyx +++ b/setools/policyrep.pyx @@ -10,7 +10,6 @@ from libc.stdint cimport uint8_t, uint16_t, uint32_t, uint64_t, uintptr_t from libc.stdio cimport FILE, fopen, fclose, snprintf from libc.stdlib cimport calloc, free from libc.string cimport memcpy, memset, strerror -from posix.stat cimport S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFREG, S_IFLNK, S_IFSOCK import dataclasses import logging @@ -25,13 +24,7 @@ from typing import TypeVar, Union cimport sepol cimport selinux -from .exception import InvalidPolicy, MLSDisabled, InvalidBoolean, InvalidCategory, InvalidClass, \ - InvalidCommon, InvalidInitialSid, InvalidLevel, InvalidLevelDecl, InvalidRange, InvalidRole, \ - InvalidSensitivity, InvalidType, InvalidUser, InvalidRuleType, InvalidBoundsType, \ - InvalidConstraintType, InvalidDefaultType, InvalidFSUseType, InvalidMLSRuleType, \ - InvalidRBACRuleType, InvalidTERuleType, SymbolUseError, RuleUseError, ConstraintUseError, \ - NoStatement, InvalidDefaultValue, InvalidDefaultRange, NoCommon, NoDefaults, \ - RuleNotConditional, TERuleNoFilename, LowLevelPolicyError +from .exception import * cdef extern from "": int vasprintf(char **strp, const char *fmt, va_list ap) @@ -44,15 +37,27 @@ cdef extern from "": cdef extern from "": ctypedef unsigned int socklen_t - cdef int AF_INET - cdef int AF_INET6 + enum: + AF_INET + AF_INET6 cdef extern from "": - cdef int INET6_ADDRSTRLEN - cdef int IPPROTO_DCCP - cdef int IPPROTO_SCTP - cdef int IPPROTO_TCP - cdef int IPPROTO_UDP + enum: + INET6_ADDRSTRLEN + IPPROTO_DCCP + IPPROTO_SCTP + IPPROTO_TCP + IPPROTO_UDP + +cdef extern from "": + enum: + S_IFBLK + S_IFCHR + S_IFDIR + S_IFIFO + S_IFREG + S_IFLNK + S_IFSOCK cdef extern from "": cdef const char *inet_ntop(int af, const void *src, char *dst, socklen_t size) @@ -66,6 +71,7 @@ include "bounds.pxi" include "constraint.pxi" include "context.pxi" include "default.pxi" +include "filecontexts.pxi" include "fscontext.pxi" include "initsid.pxi" include "mls.pxi" diff --git a/setools/policyrep/context.pxi b/setools/policyrep/context.pxi index 35d62a19..9716244c 100644 --- a/setools/policyrep/context.pxi +++ b/setools/policyrep/context.pxi @@ -29,6 +29,44 @@ cdef class Context(PolicyObject): return c + @staticmethod + cdef inline Context factory_from_string(SELinuxPolicy policy, str ctx): + """Factory function for creating Context objects from a string.""" + cdef: + Context c = Context.__new__(Context) + list items = ctx.split(":", maxsplit=3) + + try: + c.user = policy.lookup_user(items[0]) + c.role = policy.lookup_role(items[1]) + c.type_ = policy.lookup_type(items[2]) + + # object_r is a special case: it is implicitly associated with + # all users and types. + if c.role != "object_r": + if c.role not in c.user.roles: + raise InvalidContext( + f"{ctx} is invalid: Role {c.role} is not associated to user {c.user}.") + if c.type_ not in tuple(c.role.types()): + raise InvalidContext( + f"{ctx} is invalid: Type {c.type_} is not associated to role {c.role}.") + + if policy.mls: + c._range = policy.lookup_range(items[3]) + if not c._range <= c.user.mls_range: + raise InvalidContext( + f"{ctx} is invalid: Range {c._range} not in user {c.user}'s " + f"allowed range {c.user.mls_range}") + + c.policy = policy + return c + + except IndexError as ex: + raise InvalidContext("f{ctx} is invalid: Context is incomplete.") from ex + + except InvalidSymbol as ex: + raise InvalidContext(f"{ctx} is invalid: {ex}") from ex + def __str__(self): if self._range: return f"{self.user}:{self.role}:{self.type_}:{self.range_}" diff --git a/setools/policyrep/filecontexts.pxi b/setools/policyrep/filecontexts.pxi new file mode 100644 index 00000000..51c4a76e --- /dev/null +++ b/setools/policyrep/filecontexts.pxi @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: LGPL-2.1-only + +class FileContextsFiletype(PolicyEnum): + + """Enumeration of file types in FileContexts.""" + + any = 0 + dir = 1 + chr_file = 2 + blk_file = 3 + sock_file = 4 + fifo_file = 5 + lnk_file = 6 + file = 7 + + +cdef class FileContexts: + cdef: + selinux.selabel_handle *handle + + readonly SELinuxPolicy policy + readonly str path + + def __cinit__(self, policy: SELinuxPolicy | None, fc_path: str | None = None): + """ + Parameter: + policy Policy to use for context lookups. If None, lookup() + will return the raw context string. + fc_path Path to a file_contexts to open. If not specified, the + system's file_contexts will be used. + """ + + cdef selinux.selinux_opt selabel_opt + if fc_path: + selabel_opt.type = selinux.SELABEL_OPT_PATH + selabel_opt.value = fc_path + else: + selabel_opt.type = selinux.SELABEL_OPT_UNUSED + + self.handle = selinux.selabel_open(selinux.SELABEL_CTX_FILE, &selabel_opt, 1) + if self.handle == NULL: + if errno == ENOMEM: + PyErr_NoMemory() + else: + PyErr_SetFromErrnoWithFilename(OSError, fc_path) + + self.path = fc_path + self.policy = policy + + def __dealloc__(self): + if self.handle != NULL: + selinux.selabel_close(self.handle) + + def lookup(self, path: str, filetype: FileContextsFiletype = FileContextsFiletype.any): + """Look up a path in the file_contexts.""" + cdef char *ctx + if selinux.selabel_lookup_raw(self.handle, &ctx, path, filetype.value) < 0: + if errno == ENOENT: + raise NoFileContextsMatch(f"\"{path}\" ({filetype}) does not match.") + else: + PyErr_SetFromErrno(OSError) + + return self.policy.lookup_context(ctx) if self.policy else ctx diff --git a/setools/policyrep/fscontext.pxi b/setools/policyrep/fscontext.pxi index 0d7d82e1..baa8128b 100644 --- a/setools/policyrep/fscontext.pxi +++ b/setools/policyrep/fscontext.pxi @@ -79,7 +79,7 @@ cdef class GenfsFiletype(int): "chr_file": S_IFCHR} @classmethod - def from_class(cls, tclass: ObjClass | str) -> "GenfsFiletype": + def from_class(cls, tclass: ObjClass | str) -> GenfsFiletype: """Create a GenfsFiletype from an object class.""" name = tclass if isinstance(tclass, str) else tclass.name return cls(cls._sclass_to_stat[name]) diff --git a/setools/policyrep/mls.pxi b/setools/policyrep/mls.pxi index 908fd7ae..e8ed056e 100644 --- a/setools/policyrep/mls.pxi +++ b/setools/policyrep/mls.pxi @@ -439,6 +439,9 @@ cdef class Range(PolicyObject): def __hash__(self): return hash(str(self)) + def __contains__(self, other): + return self.low <= other <= self.high + def __eq__(self, other): try: return self.low == other.low and self.high == other.high @@ -450,8 +453,32 @@ cdef class Range(PolicyObject): self_str = str(self).replace(" ", "") return self_str == other_str - def __contains__(self, other): - return self.low <= other <= self.high + def __ge__(self, other): + return self.low <= other.low and self.high >= other.high + + def __gt__(self, other): + return (self.low <= other.low and self.high > other.high) \ + or (self.low < other.low and self.high >= other.high) + + def __le__(self, other): + return other.low <= self.low <= other.high \ + and other.low <= self.high <= other.high + + def __lt__(self, other): + return (other.low <= self.low and other.high > self.high) \ + or (other.low < self.low and other.high >= self.high) + + def __xor__(self, other): + # Incomp operator + return not (self >= other or self <= other) + + def dom(self, other): + """Returns if self dominates other.""" + return self >= other + + def domby(self, other): + """Returns if self is dominated by other.""" + return self <= other def statement(self): raise NoStatement diff --git a/setools/policyrep/selinux.pxd b/setools/policyrep/selinux.pxd index c22e6ecf..0b0e2708 100644 --- a/setools/policyrep/selinux.pxd +++ b/setools/policyrep/selinux.pxd @@ -11,3 +11,63 @@ cdef extern from "": const char* selinux_current_policy_path() const char* selinux_binary_policy_path() char* selinux_boolean_sub(const char *boolean_name); + +cdef extern from "": + # + # selabel_handle + # + cdef struct selabel_handle: + pass + + # + # struct selinux_opt + # + enum: + SELABEL_OPT_UNUSED + SELABEL_OPT_VALIDATE + SELABEL_OPT_DIGEST + + cdef struct selinux_opt: + int type + const char *value + + # + # functions + # + enum: + SELABEL_CTX_FILE + SELABEL_CTX_MEDIA + SELABEL_CTX_X + SELABEL_CTX_DB + + enum: + SELABEL_OPT_PATH + SELABEL_OPT_BASEONLY + SELABEL_OPT_SUBSET + + enum: + SELABEL_X_PROP + SELABEL_X_SELN + SELABEL_X_EXT + SELABEL_X_EVENT + SELABEL_X_CLIENT + SELABEL_X_POLYPROP + SELABEL_X_POLYSELN + + enum: + SELABEL_DB_DATABASE + SELABEL_DB_SCHEMA + SELABEL_DB_TABLE + SELABEL_DB_COLUMN + SELABEL_DB_TUPLE + SELABEL_DB_PROCEDURE + SELABEL_DB_SEQUENCE + SELABEL_DB_BLOB + SELABEL_DB_VIEW + SELABEL_DB_LANGUAGE + SELABEL_DB_EXCEPTION + SELABEL_DB_DATATYPE + + selabel_handle* selabel_open(unsigned int backend, const selinux_opt * options, unsigned nopt) + int selabel_lookup_raw(selabel_handle * handle, char ** con, const char * key, int type) + void selabel_close(selabel_handle *hnd) diff --git a/setools/policyrep/selinuxpolicy.pxi b/setools/policyrep/selinuxpolicy.pxi index 2c7977ce..8a472dd6 100644 --- a/setools/policyrep/selinuxpolicy.pxi +++ b/setools/policyrep/selinuxpolicy.pxi @@ -367,6 +367,10 @@ cdef class SELinuxPolicy: raise InvalidCommon(f"{name} is not a valid common") + def lookup_context(self, ctx): + """Look up a context.""" + return Context.factory_from_string(self, ctx) + def lookup_level(self, level): """Look up a MLS level.""" return Level.factory_from_string(self, level) diff --git a/setools/policyrep/sepol.pxd b/setools/policyrep/sepol.pxd index 4db8fc3c..19191ba4 100644 --- a/setools/policyrep/sepol.pxd +++ b/setools/policyrep/sepol.pxd @@ -24,15 +24,17 @@ cdef extern from "": cdef extern from "": - cdef int SECURITY_FS_USE_XATTR - cdef int SECURITY_FS_USE_TRANS - cdef int SECURITY_FS_USE_TASK - cdef int SECURITY_FS_USE_GENFS - cdef int SECURITY_FS_USE_NONE + enum: + SECURITY_FS_USE_XATTR + SECURITY_FS_USE_TRANS + SECURITY_FS_USE_TASK + SECURITY_FS_USE_GENFS + SECURITY_FS_USE_NONE cdef extern from "": - cdef int SELINUX_MAGIC + enum: + SELINUX_MAGIC ctypedef char* sepol_security_context_t ctypedef uint32_t sepol_access_vector_t @@ -54,8 +56,9 @@ cdef extern from "": # # ebitmap_t # - cdef int MAPBIT - cdef int MAPSIZE + enum: + MAPBIT + MAPSIZE cdef struct ebitmap: ebitmap_node_t *node @@ -127,22 +130,23 @@ cdef extern from "": # # avtab_key_t # - cdef int AVTAB_ALLOWED - cdef int AVTAB_AUDITALLOW - cdef int AVTAB_AUDITDENY - cdef int AVTAB_NEVERALLOW - cdef int AVTAB_AV - cdef int AVTAB_TRANSITION - cdef int AVTAB_MEMBER - cdef int AVTAB_CHANGE - cdef int AVTAB_TYPE - cdef int AVTAB_XPERMS_ALLOWED - cdef int AVTAB_XPERMS_AUDITALLOW - cdef int AVTAB_XPERMS_DONTAUDIT - cdef int AVTAB_XPERMS_NEVERALLOW - cdef int AVTAB_XPERMS - cdef int AVTAB_ENABLED_OLD - cdef int AVTAB_ENABLED + enum: + AVTAB_ALLOWED + AVTAB_AUDITALLOW + AVTAB_AUDITDENY + AVTAB_NEVERALLOW + AVTAB_AV + AVTAB_TRANSITION + AVTAB_MEMBER + AVTAB_CHANGE + AVTAB_TYPE + AVTAB_XPERMS_ALLOWED + AVTAB_XPERMS_AUDITALLOW + AVTAB_XPERMS_DONTAUDIT + AVTAB_XPERMS_NEVERALLOW + AVTAB_XPERMS + AVTAB_ENABLED_OLD + AVTAB_ENABLED cdef struct avtab_key: uint16_t source_type @@ -155,9 +159,10 @@ cdef extern from "": # # avtab_extended_perms_t # - cdef int AVTAB_XPERMS_IOCTLFUNCTION - cdef int AVTAB_XPERMS_IOCTLDRIVER - cdef int AVTAB_XPERMS_NLMSG + enum: + AVTAB_XPERMS_IOCTLFUNCTION + AVTAB_XPERMS_IOCTLDRIVER + AVTAB_XPERMS_NLMSG cdef struct avtab_extended_perms: uint8_t specified @@ -271,10 +276,11 @@ cdef extern from "": # # sidtab_t # - cdef int SIDTAB_HASH_BITS - cdef int SIDTAB_HASH_BUCKETS - cdef int SIDTAB_HASH_MASK - cdef int SIDTAB_SIZE + enum: + SIDTAB_HASH_BITS + SIDTAB_HASH_BUCKETS + SIDTAB_HASH_MASK + SIDTAB_SIZE ctypedef struct sidtab_t: sidtab_ptr_t *htable @@ -292,8 +298,9 @@ cdef extern from "": #endif """ - cdef int COND_EXPR_MAXDEPTH - cdef int COND_MAX_BOOLS + enum: + COND_EXPR_MAXDEPTH + COND_MAX_BOOLS # # cond_av_list_t @@ -307,14 +314,15 @@ cdef extern from "": # # cond_expr_t # - cdef int COND_BOOL - cdef int COND_NOT - cdef int COND_OR - cdef int COND_AND - cdef int COND_XOR - cdef int COND_EQ - cdef int COND_NEQ - cdef int COND_LAST + enum: + COND_BOOL + COND_NOT + COND_OR + COND_AND + COND_XOR + COND_EQ + COND_NEQ + COND_LAST cdef struct cond_expr: uint32_t expr_type @@ -326,7 +334,8 @@ cdef extern from "": # # cond_node_t # - cdef int COND_NODE_FLAGS_TUNABLE + enum: + COND_NODE_FLAGS_TUNABLE cdef struct cond_node: int cur_state @@ -346,31 +355,33 @@ cdef extern from "": cdef extern from "": - cdef int CEXPR_NOT - cdef int CEXPR_AND - cdef int CEXPR_OR - cdef int CEXPR_ATTR - cdef int CEXPR_NAMES - cdef int CEXPR_USER - cdef int CEXPR_ROLE - cdef int CEXPR_TYPE - cdef int CEXPR_TARGET - cdef int CEXPR_XTARGET - cdef int CEXPR_L1L2 - cdef int CEXPR_L1H2 - cdef int CEXPR_H1L2 - cdef int CEXPR_H1H2 - cdef int CEXPR_L1H1 - cdef int CEXPR_L2H2 + enum: + CEXPR_NOT + CEXPR_AND + CEXPR_OR + CEXPR_ATTR + CEXPR_NAMES + CEXPR_USER + CEXPR_ROLE + CEXPR_TYPE + CEXPR_TARGET + CEXPR_XTARGET + CEXPR_L1L2 + CEXPR_L1H2 + CEXPR_H1L2 + CEXPR_H1H2 + CEXPR_L1H1 + CEXPR_L2H2 # # constraint_expr_t # - cdef int CEXPR_EQ - cdef int CEXPR_NEQ - cdef int CEXPR_DOM - cdef int CEXPR_DOMBY - cdef int CEXPR_INCOMP + enum: + CEXPR_EQ + CEXPR_NEQ + CEXPR_DOM + CEXPR_DOMBY + CEXPR_INCOMP cdef struct constraint_expr: uint32_t expr_type @@ -411,8 +422,9 @@ cdef extern from "": # # role_set_t # - cdef int ROLE_STAR - cdef int ROLE_COMP + enum: + ROLE_STAR + ROLE_COMP cdef struct role_set: ebitmap_t roles @@ -423,8 +435,9 @@ cdef extern from "": # # type_set_t # - cdef int TYPE_STAR - cdef int TYPE_COMP + enum: + TYPE_STAR + TYPE_COMP cdef struct type_set: ebitmap_t types @@ -436,10 +449,11 @@ cdef extern from "": # # av_extended_perms_t # - cdef int AVRULE_XPERMS_IOCTLFUNCTION - cdef int AVRULE_XPERMS_IOCTLDRIVER - cdef int AVRULE_XPERMS_NLMSG - cdef int EXTENDED_PERMS_LEN + enum: + AVRULE_XPERMS_IOCTLFUNCTION + AVRULE_XPERMS_IOCTLDRIVER + AVRULE_XPERMS_NLMSG + EXTENDED_PERMS_LEN cdef struct av_extended_perms: uint8_t specified @@ -453,22 +467,23 @@ cdef extern from "": # # avrule_t # - cdef int AVRULE_ALLOWED - cdef int AVRULE_AUDITALLOW - cdef int AVRULE_AUDITDENY - cdef int AVRULE_DONTAUDIT - cdef int AVRULE_NEVERALLOW - cdef int AVRULE_AV - cdef int AVRULE_TRANSITION - cdef int AVRULE_MEMBER - cdef int AVRULE_CHANGE - cdef int AVRULE_TYPE - cdef int AVRULE_XPERMS_ALLOWED - cdef int AVRULE_XPERMS_AUDITALLOW - cdef int AVRULE_XPERMS_DONTAUDIT - cdef int AVRULE_XPERMS_NEVERALLOW - cdef int AVRULE_XPERMS - cdef int RULE_SELF + enum: + AVRULE_ALLOWED + AVRULE_AUDITALLOW + AVRULE_AUDITDENY + AVRULE_DONTAUDIT + AVRULE_NEVERALLOW + AVRULE_AV + AVRULE_TRANSITION + AVRULE_MEMBER + AVRULE_CHANGE + AVRULE_TYPE + AVRULE_XPERMS_ALLOWED + AVRULE_XPERMS_AUDITALLOW + AVRULE_XPERMS_DONTAUDIT + AVRULE_XPERMS_NEVERALLOW + AVRULE_XPERMS + RULE_SELF cdef struct avrule: uint32_t specified @@ -505,15 +520,16 @@ cdef extern from "": # # class_datum_t # - cdef int DEFAULT_SOURCE - cdef int DEFAULT_TARGET - cdef int DEFAULT_SOURCE_LOW - cdef int DEFAULT_SOURCE_HIGH - cdef int DEFAULT_SOURCE_LOW_HIGH - cdef int DEFAULT_TARGET_LOW - cdef int DEFAULT_TARGET_HIGH - cdef int DEFAULT_TARGET_LOW_HIGH - cdef int DEFAULT_GLBLUB + enum: + DEFAULT_SOURCE + DEFAULT_TARGET + DEFAULT_SOURCE_LOW + DEFAULT_SOURCE_HIGH + DEFAULT_SOURCE_LOW_HIGH + DEFAULT_TARGET_LOW + DEFAULT_TARGET_HIGH + DEFAULT_TARGET_LOW_HIGH + DEFAULT_GLBLUB cdef struct class_datum: symtab_datum_t s @@ -532,7 +548,8 @@ cdef extern from "": # # cond_bool_datum_t # - cdef int COND_BOOL_FLAGS_TUNABLE + enum: + COND_BOOL_FLAGS_TUNABLE cdef struct cond_bool_datum: # Boolean data type symtab_datum_t s @@ -636,24 +653,25 @@ cdef extern from "": # # ocontext_t # - cdef int OCON_ISID - cdef int OCON_FS - cdef int OCON_PORT - cdef int OCON_NETIF - cdef int OCON_NODE - cdef int OCON_FSUSE - cdef int OCON_NODE6 - cdef int OCON_IBPKEY - cdef int OCON_IBENDPORT - - cdef int OCON_XEN_ISID - cdef int OCON_XEN_PIRQ - cdef int OCON_XEN_IOPORT - cdef int OCON_XEN_IOMEM - cdef int OCON_XEN_PCIDEVICE - cdef int OCON_XEN_DEVICETREE - - cdef int OCON_NUM + enum: + OCON_ISID + OCON_FS + OCON_PORT + OCON_NETIF + OCON_NODE + OCON_FSUSE + OCON_NODE6 + OCON_IBPKEY + OCON_IBENDPORT + OCON_NUM + + enum: + OCON_XEN_ISID + OCON_XEN_PIRQ + OCON_XEN_IOPORT + OCON_XEN_IOMEM + OCON_XEN_PCIDEVICE + OCON_XEN_DEVICETREE cdef struct ocontext: ocontext_u_union u @@ -705,8 +723,9 @@ cdef extern from "": # # role_datum_t # - cdef int ROLE_ROLE - cdef int ROLE_ATTRIB + enum: + ROLE_ROLE + ROLE_ATTRIB cdef struct role_datum: symtab_datum_t s @@ -746,13 +765,14 @@ cdef extern from "": # # type_datum_t # - cdef int TYPE_TYPE - cdef int TYPE_ATTRIB - cdef int TYPE_ALIAS - cdef int TYPE_FLAGS_PERMISSIVE - cdef int TYPE_FLAGS_EXPAND_ATTR_TRUE - cdef int TYPE_FLAGS_EXPAND_ATTR_FALSE - cdef int TYPE_FLAGS_EXPAND_ATTR + enum: + TYPE_TYPE + TYPE_ATTRIB + TYPE_ALIAS + TYPE_FLAGS_PERMISSIVE + TYPE_FLAGS_EXPAND_ATTR_TRUE + TYPE_FLAGS_EXPAND_ATTR_FALSE + TYPE_FLAGS_EXPAND_ATTR cdef struct type_datum: symtab_datum_t s @@ -782,18 +802,20 @@ cdef extern from "": # # Policy DB # - cdef int POLICYDB_VERSION_MAX - cdef int POLICYDB_VERSION_MIN - - cdef int SYM_COMMONS - cdef int SYM_CLASSES - cdef int SYM_ROLES - cdef int SYM_TYPES - cdef int SYM_USERS - cdef int SYM_BOOLS - cdef int SYM_LEVELS - cdef int SYM_CATS - cdef int SYM_NUM + enum: + POLICYDB_VERSION_MAX + POLICYDB_VERSION_MIN + + enum: + SYM_COMMONS + SYM_CLASSES + SYM_ROLES + SYM_TYPES + SYM_USERS + SYM_BOOLS + SYM_LEVELS + SYM_CATS + SYM_NUM cdef struct policydb: uint32_t policy_type @@ -846,11 +868,13 @@ cdef extern from "": ctypedef sepol_policydb sepol_policydb_t - cdef int SEPOL_DENY_UNKNOWN - cdef int SEPOL_REJECT_UNKNOWN - cdef int SEPOL_ALLOW_UNKNOWN - cdef int SEPOL_TARGET_SELINUX - cdef int SEPOL_TARGET_XEN + enum: + SEPOL_DENY_UNKNOWN + SEPOL_REJECT_UNKNOWN + SEPOL_ALLOW_UNKNOWN + SEPOL_TARGET_SELINUX + SEPOL_TARGET_XEN + cdef int sepol_policy_kern_vers_min() cdef int sepol_policy_kern_vers_max() diff --git a/tests/library/mcp/test_server.py b/tests/library/mcp/test_server.py index 2f8cbd42..bd6802b0 100644 --- a/tests/library/mcp/test_server.py +++ b/tests/library/mcp/test_server.py @@ -22,6 +22,7 @@ DTA_POLICY = "tests/library/dta.conf" DIFF_LEFT_POLICY = "tests/library/diff_left.conf" DIFF_RIGHT_POLICY = "tests/library/diff_right.conf" +FILE_CONTEXTS = "tests/library/policyrep/file_contexts" @pytest.fixture(scope="class") @@ -55,13 +56,13 @@ def assert_payload(payload: str) -> dict: @pytest.mark.obj_args(SELINUX_POLICY) class TestGetPolicyInfo: def test_returns_valid_json(self, mcp_server: SEToolsMCPServer) -> None: - result = assert_payload(mcp_server.setools_get_policy_info())["result"] + result = assert_payload(mcp_server.setools_tool_get_policy_info())["result"] assert "version" in result assert "mls" in result assert "counts" in result def test_counts_are_positive(self, mcp_server: SEToolsMCPServer) -> None: - counts = assert_payload(mcp_server.setools_get_policy_info())["result"]["counts"] + counts = assert_payload(mcp_server.setools_tool_get_policy_info())["result"]["counts"] assert counts["types"] > 0 assert counts["allow_rules"] > 0 @@ -69,48 +70,48 @@ def test_counts_are_positive(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(XEN_POLICY, xen=True) class TestGetPolicyInfoXen: def test_xen_policy(self, mcp_server: SEToolsMCPServer) -> None: - result = assert_payload(mcp_server.setools_get_policy_info())["result"] + result = assert_payload(mcp_server.setools_tool_get_policy_info())["result"] assert result["target_platform"] == "xen" @pytest.mark.obj_args(SELINUX_POLICY) class TestSearchTERules: def test_unfiltered_returns_rules(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_te_rules(max_results=5)) + data = assert_payload(mcp_server.setools_tool_search_te_rules(max_results=5)) assert data["count"] > 0 assert "result" in data def test_source_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_te_rules(ruletypes=["allow"], - source="type30", - source_indirect=False, - max_results=10)) + data = assert_payload(mcp_server.setools_tool_search_te_rules(ruletypes=["allow"], + source="type30", + source_indirect=False, + max_results=10)) for rule in data["result"]: assert rule["ruletype"] == "allow" assert rule["source"] == "type30" def test_tclass_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_te_rules(ruletypes=["allow"], - tclass=["infoflow"], - max_results=5)) + data = assert_payload(mcp_server.setools_tool_search_te_rules(ruletypes=["allow"], + tclass=["infoflow"], + max_results=5)) for rule in data["result"]: assert rule["tclass"] == "infoflow" def test_truncation(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_te_rules(max_results=2)) + data = assert_payload(mcp_server.setools_tool_search_te_rules(max_results=2)) assert data["count"] == 2 assert data["truncated"] is True def test_no_results(self, mcp_server: SEToolsMCPServer) -> None: # Use a regex pattern that matches nothing - data = assert_payload(mcp_server.setools_search_te_rules(source="^__nonexistent_type_z__$", - source_regex=True)) + data = assert_payload(mcp_server.setools_tool_search_te_rules( + source="^__nonexistent_type_z__$", source_regex=True)) assert data["count"] == 0 assert data["truncated"] is False def test_rule_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_te_rules(ruletypes=["allow"], - max_results=1)) + data = assert_payload(mcp_server.setools_tool_search_te_rules(ruletypes=["allow"], + max_results=1)) rule = data["result"][0] for key in ("statement", "ruletype", "source", "target", "tclass"): assert key in rule @@ -119,23 +120,23 @@ def test_rule_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None @pytest.mark.obj_args(SELINUX_POLICY) class TestSearchRBACRules: def test_unfiltered_returns_rules(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_rbac_rules(max_results=5)) + data = assert_payload(mcp_server.setools_tool_search_rbac_rules(max_results=5)) assert data["count"] > 0 def test_role_allow_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_rbac_rules(ruletypes=["allow"], - max_results=5)) + data = assert_payload(mcp_server.setools_tool_search_rbac_rules(ruletypes=["allow"], + max_results=5)) for rule in data["result"]: assert rule["ruletype"] == "allow" def test_role_transition_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_rbac_rules(ruletypes=["role_transition"], - max_results=5)) + data = assert_payload(mcp_server.setools_tool_search_rbac_rules( + ruletypes=["role_transition"], max_results=5)) for rule in data["result"]: assert rule["ruletype"] == "role_transition" def test_rule_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_rbac_rules(max_results=1)) + data = assert_payload(mcp_server.setools_tool_search_rbac_rules(max_results=1)) rule = data["result"][0] for key in ("statement", "ruletype", "source", "target"): assert key in rule @@ -144,17 +145,17 @@ def test_rule_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None @pytest.mark.obj_args(SELINUX_POLICY) class TestSearchMLSRules: def test_unfiltered_returns_rules(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_mls_rules(max_results=5)) + data = assert_payload(mcp_server.setools_tool_search_mls_rules(max_results=5)) assert data["count"] > 0 def test_range_transition_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_mls_rules(ruletypes=["range_transition"], - max_results=5)) + data = assert_payload(mcp_server.setools_tool_search_mls_rules( + ruletypes=["range_transition"], max_results=5)) for rule in data["result"]: assert rule["ruletype"] == "range_transition" def test_rule_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_search_mls_rules(max_results=1)) + data = assert_payload(mcp_server.setools_tool_search_mls_rules(max_results=1)) rule = data["result"][0] for key in ("statement", "ruletype", "source", "target", "tclass", "default"): assert key in rule @@ -163,28 +164,28 @@ def test_rule_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None @pytest.mark.obj_args(SELINUX_POLICY) class TestListTypes: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_types(max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_types(max_results=10)) assert data["count"] > 0 def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_types(name="type18")) + data = assert_payload(mcp_server.setools_tool_list_types(name="type18")) assert data["count"] == 1 assert data["result"][0]["name"] == "type18" def test_name_regex(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_types(name="type2.", name_regex=True, - max_results=20)) + data = assert_payload(mcp_server.setools_tool_list_types(name="type2.", name_regex=True, + max_results=20)) for t in data["result"]: assert "type2" in t["name"] def test_type_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_types(max_results=1)) + data = assert_payload(mcp_server.setools_tool_list_types(max_results=1)) t = data["result"][0] for key in ("name", "permissive", "attributes", "aliases"): assert key in t def test_permissive_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_types(permissive=True, max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_types(permissive=True, max_results=10)) # All returned types should be permissive for t in data["result"]: assert t["permissive"] @@ -193,22 +194,22 @@ def test_permissive_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListAttributes: def test_unfiltered_returns_attrs(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_type_attributes(max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_type_attributes(max_results=10)) assert data["count"] > 0 def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_type_attributes(name="attr0")) + data = assert_payload(mcp_server.setools_tool_list_type_attributes(name="attr0")) assert data["count"] == 1 assert data["result"][0]["name"] == "attr0" def test_attr_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_type_attributes(max_results=1)) + data = assert_payload(mcp_server.setools_tool_list_type_attributes(max_results=1)) attr = data["result"][0] for key in ("name", "types"): assert key in attr def test_domain_attr_contains_type(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_type_attributes(name="attr0")) + data = assert_payload(mcp_server.setools_tool_list_type_attributes(name="attr0")) types = data["result"][0]["types"] assert "type1" in types @@ -216,15 +217,15 @@ def test_domain_attr_contains_type(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListRoles: def test_unfiltered_returns_roles(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_roles(max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_roles(max_results=10)) assert data["count"] > 0 def test_object_r_present(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_roles(name="object_r")) + data = assert_payload(mcp_server.setools_tool_list_roles(name="object_r")) assert data["count"] == 1 def test_role_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_roles(max_results=1)) + data = assert_payload(mcp_server.setools_tool_list_roles(max_results=1)) role = data["result"][0] for key in ("name", "types"): assert key in role @@ -233,26 +234,27 @@ def test_role_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None @pytest.mark.obj_args(SELINUX_POLICY) class TestListRoleTypes: def test_types(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_role_types(type_name="type0")) + data = assert_payload(mcp_server.setools_tool_list_role_types(type_name="type0")) assert data["count"] > 0 def test_no_results_for_nonexistent_type(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_role_types(type_name="__nonexistent_xyz__")) + data = assert_payload(mcp_server.setools_tool_list_role_types( + type_name="__nonexistent_xyz__")) assert data["count"] == 0 @pytest.mark.obj_args(SELINUX_POLICY) class TestListUsers: def test_unfiltered_returns_users(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_users(max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_users(max_results=10)) assert data["count"] > 0 def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_users(name="user1")) + data = assert_payload(mcp_server.setools_tool_list_users(name="user1")) assert data["count"] == 1 def test_user_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_users(max_results=1)) + data = assert_payload(mcp_server.setools_tool_list_users(max_results=1)) user = data["result"][0] for key in ("name", "roles"): assert key in user @@ -261,11 +263,11 @@ def test_user_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None @pytest.mark.obj_args(SELINUX_POLICY) class TestListClasses: def test_unfiltered_returns_classes(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_classes(max_results=20)) + data = assert_payload(mcp_server.setools_tool_list_classes(max_results=20)) assert data["count"] > 0 def test_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_classes(name="infoflow7")) + data = assert_payload(mcp_server.setools_tool_list_classes(name="infoflow7")) assert data["count"] == 1 assert data["result"][0]["name"] == "infoflow7" @@ -273,11 +275,11 @@ def test_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListCommons: def test_unfiltered_returns_commons(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_commons(max_results=20)) + data = assert_payload(mcp_server.setools_tool_list_commons(max_results=20)) assert data["count"] > 0 def test_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_commons(name="hi_c")) + data = assert_payload(mcp_server.setools_tool_list_commons(name="hi_c")) assert data["count"] == 1 assert len(data["result"][0]["perms"]) > 0 @@ -285,16 +287,16 @@ def test_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListBooleans: def test_unfiltered_returns_booleans(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_booleans(max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_booleans(max_results=10)) assert data["count"] > 0 def test_state_true_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_booleans(state=True, max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_booleans(state=True, max_results=10)) for b in data["result"]: assert b["default_state"] is True def test_state_false_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_booleans(state=False, max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_booleans(state=False, max_results=10)) for b in data["result"]: assert b["default_state"] is False @@ -302,11 +304,11 @@ def test_state_false_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListSensitivities: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_sensitivities(max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_sensitivities(max_results=10)) assert data["count"] > 0 def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_sensitivities(name="s0")) + data = assert_payload(mcp_server.setools_tool_list_sensitivities(name="s0")) assert data["count"] == 1 assert data["result"][0]["name"] == "s0" @@ -314,36 +316,36 @@ def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListCategories: def test_unfiltered_returns_categories(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_categories(max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_categories(max_results=10)) assert data["count"] > 0 def test_filteer(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_categories(name="c0")) + data = assert_payload(mcp_server.setools_tool_list_categories(name="c0")) assert data["count"] == 1 @pytest.mark.obj_args(SELINUX_POLICY) class TestListPolcaps: def test_unfiltered_returns_polcaps(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_polcaps()) + data = assert_payload(mcp_server.setools_tool_list_polcaps()) assert data["count"] > 0 def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_polcaps(name="open_perms")) + data = assert_payload(mcp_server.setools_tool_list_polcaps(name="open_perms")) assert data["count"] == 1 @pytest.mark.obj_args(SELINUX_POLICY) class TestListPermissiveTypes: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_permissive_types()) + data = assert_payload(mcp_server.setools_tool_list_permissive_types()) assert data["count"] > 0 # @pytest.mark.obj_args(SELINUX_POLICY) # class TestListTypebounds: # def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: -# data = assert_payload(mcp_server.setools_list_typebounds()) +# data = assert_payload(mcp_server.setools_tool_list_typebounds()) # assert data["count"] > 0 # assert "typebounds" in data @@ -351,38 +353,38 @@ def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListConstraints: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_constraints()) + data = assert_payload(mcp_server.setools_tool_list_constraints()) assert data["count"] > 0 def test_tclass_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_constraints(tclass=["infoflow"])) + data = assert_payload(mcp_server.setools_tool_list_constraints(tclass=["infoflow"])) for c in data["result"]: assert c["tclass"] == "infoflow" def test_explicit_ruletypes(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_constraints(ruletypes=["constrain"])) + data = assert_payload(mcp_server.setools_tool_list_constraints(ruletypes=["constrain"])) for c in data["result"]: assert c["ruletype"] == "constrain" def test_constraint_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_constraints()) + data = assert_payload(mcp_server.setools_tool_list_constraints()) c = data["result"][0] for key in ("statement", "ruletype", "tclass"): assert key in c def test_invalid_ruletype_raises(self, mcp_server: SEToolsMCPServer) -> None: with pytest.raises(ValueError): - mcp_server.setools_list_constraints(ruletypes=["not_a_real_ruletype"]) + mcp_server.setools_tool_list_constraints(ruletypes=["not_a_real_ruletype"]) # @pytest.mark.obj_args(SELINUX_POLICY) # class TestListDefaults: # def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: -# data = assert_payload(mcp_server.setools_list_defaults()) +# data = assert_payload(mcp_server.setools_tool_list_defaults()) # assert data["count"] > 0 # def test_ruletype_filter(self, mcp_server: SEToolsMCPServer) -> None: -# data = assert_payload(mcp_server.setools_list_defaults(ruletypes=["default_role"])) +# data = assert_payload(mcp_server.setools_tool_list_defaults(ruletypes=["default_role"])) # for d in data["defaults"]: # assert d["ruletype"] == "default_role" @@ -390,17 +392,17 @@ def test_invalid_ruletype_raises(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListFsUses: def test_unfiltered_returns_entries(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_fs_uses()) + data = assert_payload(mcp_server.setools_tool_list_fs_uses()) assert data["count"] > 0 def test_fs_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_fs_uses(fs="fs0")) + data = assert_payload(mcp_server.setools_tool_list_fs_uses(fs="fs0")) assert data["count"] > 0 for fsu in data["result"]: assert fsu["fs"] == "fs0" def test_ruletype_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_fs_uses(ruletypes=["fs_use_xattr"])) + data = assert_payload(mcp_server.setools_tool_list_fs_uses(ruletypes=["fs_use_xattr"])) for fsu in data["result"]: assert fsu["ruletype"] == "fs_use_xattr" @@ -408,17 +410,17 @@ def test_ruletype_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListGenfscons: def test_unfiltered_returns_entries(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_genfscons()) + data = assert_payload(mcp_server.setools_tool_list_genfscons()) assert data["count"] > 0 def test_fs_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_genfscons(fs="fs149")) + data = assert_payload(mcp_server.setools_tool_list_genfscons(fs="fs149")) assert data["count"] > 0 for g in data["result"]: assert g["fs"] == "fs149" def test_genfscon_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_genfscons(max_results=1)) + data = assert_payload(mcp_server.setools_tool_list_genfscons(max_results=1)) g = data["result"][0] for key in ("statement", "fs", "path", "context"): assert key in g @@ -427,11 +429,11 @@ def test_genfscon_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> @pytest.mark.obj_args(SELINUX_POLICY) class TestListInitialsids: def test_unfiltered_returns_entries(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_initialsids()) + data = assert_payload(mcp_server.setools_tool_list_initialsids()) assert data["count"] > 0 def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_initialsids(name="kernel")) + data = assert_payload(mcp_server.setools_tool_list_initialsids(name="kernel")) assert data["count"] == 1 assert data["result"][0]["name"] == "kernel" @@ -439,17 +441,17 @@ def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListPortcons: def test_unfiltered_returns_entries(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_portcons()) + data = assert_payload(mcp_server.setools_tool_list_portcons()) assert data["count"] > 0 def test_port_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_portcons(ports="80")) + data = assert_payload(mcp_server.setools_tool_list_portcons(ports="80")) assert data["count"] > 0 for pc in data["result"]: assert pc["ports_low"] <= 80 <= pc["ports_high"] def test_protocol_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_portcons(protocol="tcp", max_results=10)) + data = assert_payload(mcp_server.setools_tool_list_portcons(protocol="tcp", max_results=10)) for pc in data["result"]: assert pc["protocol"] == "tcp" @@ -457,124 +459,125 @@ def test_protocol_filter(self, mcp_server: SEToolsMCPServer) -> None: @pytest.mark.obj_args(SELINUX_POLICY) class TestListNetifcons: def test_unfiltered_returns_entries(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_netifcons()) + data = assert_payload(mcp_server.setools_tool_list_netifcons()) assert data["count"] > 0 def test_name_filter(self, mcp_server: SEToolsMCPServer) -> None: - data_filtered = assert_payload(mcp_server.setools_list_netifcons(name="eth0")) + data_filtered = assert_payload(mcp_server.setools_tool_list_netifcons(name="eth0")) assert data_filtered["count"] >= 1 @pytest.mark.obj_args(SELINUX_POLICY) class TestListNodecons: def test_unfiltered_returns_entries(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_nodecons()) + data = assert_payload(mcp_server.setools_tool_list_nodecons()) assert data["count"] > 0 def test_nodecon_dict_has_required_keys(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_nodecons(max_results=1)) + data = assert_payload(mcp_server.setools_tool_list_nodecons(max_results=1)) n = data["result"][0] for key in ("statement", "network", "ip_version", "context"): assert key in n def test_ipv4_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_nodecons(ip_version="ipv4", max_results=5)) + data = assert_payload(mcp_server.setools_tool_list_nodecons(ip_version="ipv4", + max_results=5)) for n in data["result"]: assert n["ip_version"] == "ipv4" def test_network_filter(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_nodecons(network="0.0.0.0/0")) + data = assert_payload(mcp_server.setools_tool_list_nodecons(network="0.0.0.0/0")) assert data["count"] > 0 @pytest.mark.obj_args(SELINUX_POLICY) class TestListIbpkeycons: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_ibpkeycons()) + data = assert_payload(mcp_server.setools_tool_list_ibpkeycons()) assert data["count"] > 0 @pytest.mark.obj_args(SELINUX_POLICY) class TestListIbendportcons: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_ibendportcons()) + data = assert_payload(mcp_server.setools_tool_list_ibendportcons()) assert data["count"] > 0 @pytest.mark.obj_args(XEN_POLICY, xen=True) class TestListIomemcons: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_iomemcons()) + data = assert_payload(mcp_server.setools_tool_list_iomemcons()) assert data["count"] > 0 @pytest.mark.obj_args(XEN_POLICY, xen=True) class TestListIoportcons: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_ioportcons()) + data = assert_payload(mcp_server.setools_tool_list_ioportcons()) assert data["count"] > 0 @pytest.mark.obj_args(XEN_POLICY, xen=True) class TestListPcidevicecons: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_pcidevicecons()) + data = assert_payload(mcp_server.setools_tool_list_pcidevicecons()) assert data["count"] > 0 @pytest.mark.obj_args(XEN_POLICY, xen=True) class TestListPirqcons: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_pirqcons()) + data = assert_payload(mcp_server.setools_tool_list_pirqcons()) assert data["count"] > 0 @pytest.mark.obj_args(XEN_POLICY, xen=True) class TestListDevicetreecons: def test_unfiltered(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_list_devicetreecons()) + data = assert_payload(mcp_server.setools_tool_list_devicetreecons()) assert data["count"] > 0 @pytest.mark.obj_args(DTA_POLICY) class TestAnalyzeDTA: def test_transitions_out(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_analyze_dta(mode="TransitionsOut", source="start", - max_results=5)) + data = assert_payload(mcp_server.setools_tool_analyze_dta(mode="TransitionsOut", + source="start", max_results=5)) assert data["count"] > 0 def test_transitions_in(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_analyze_dta(mode="TransitionsIn", target="trans1", - max_results=5)) + data = assert_payload(mcp_server.setools_tool_analyze_dta(mode="TransitionsIn", + target="trans1", max_results=5)) assert data["count"] > 0 def test_shortest_paths(self, mcp_server: SEToolsMCPServer) -> None: # Find a target that kernel_t can reach - data = assert_payload(mcp_server.setools_analyze_dta(mode="ShortestPaths", source="trans2", - target="trans3", max_results=5)) + data = assert_payload(mcp_server.setools_tool_analyze_dta(mode="ShortestPaths", + source="trans2", target="trans3", max_results=5)) assert data["count"] > 0 assert isinstance(data["result"][0]["path"], list) def test_invalid_mode_raises(self, mcp_server: SEToolsMCPServer) -> None: with pytest.raises(ValueError): - mcp_server.setools_analyze_dta(mode="bad_mode") + mcp_server.setools_tool_analyze_dta(mode="bad_mode") @pytest.mark.obj_args(INFOFLOW_POLICY) class TestAnalyzeInfoFlow: def test_flows_out(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_analyze_info_flow(mode="FlowsOut", source="node1", - perm_map_path=PERM_MAP, max_results=5)) + data = assert_payload(mcp_server.setools_tool_analyze_info_flow(mode="FlowsOut", + source="node1", perm_map_path=PERM_MAP, max_results=5)) assert data["count"] > 0 def test_flows_in(self, mcp_server: SEToolsMCPServer) -> None: - data = assert_payload(mcp_server.setools_analyze_info_flow(mode="FlowsIn", target="node2", - perm_map_path=PERM_MAP, max_results=5)) + data = assert_payload(mcp_server.setools_tool_analyze_info_flow(mode="FlowsIn", + target="node2", perm_map_path=PERM_MAP, max_results=5)) assert data["count"] > 0 def test_shortest_paths(self, mcp_server: SEToolsMCPServer) -> None: # Find a target that kernel_t can flow to - data = assert_payload(mcp_server.setools_analyze_info_flow(mode="ShortestPaths", + data = assert_payload(mcp_server.setools_tool_analyze_info_flow(mode="ShortestPaths", source="node1", target="node4", perm_map_path=PERM_MAP, max_results=5)) assert data["count"] > 0 @@ -582,14 +585,14 @@ def test_shortest_paths(self, mcp_server: SEToolsMCPServer) -> None: def test_invalid_mode_raises(self, mcp_server: SEToolsMCPServer) -> None: with pytest.raises(ValueError): - mcp_server.setools_analyze_info_flow(mode="bad_mode") + mcp_server.setools_tool_analyze_info_flow(mode="bad_mode") @pytest.mark.obj_args(DIFF_LEFT_POLICY, DIFF_RIGHT_POLICY) class TestDiffPolicies: def test_identical_policies(self, mcp_server2: tuple[SEToolsMCPServer, str, str]) -> None: server, left, _ = mcp_server2 - data = assert_payload(server.setools_diff_policies( + data = assert_payload(server.setools_tool_diff_policies( left, left, components=["types"],)) assert data["result"]["types"]["added"] == [] assert data["result"]["types"]["removed"] == [] @@ -599,7 +602,7 @@ def test_identical_policies(self, mcp_server2: tuple[SEToolsMCPServer, str, str] def test_diff(self, mcp_server2: tuple[SEToolsMCPServer, str, str]) -> None: # tests/library/mcp/diff_left.conf vs policy-full.33 differ in TE rules server, left, right = mcp_server2 - data = assert_payload(server.setools_diff_policies( + data = assert_payload(server.setools_tool_diff_policies( left, right, components=["te_rules", "types"])) diffs = data["result"] # The two full policy files should differ in at least TE rules or types @@ -617,12 +620,12 @@ def test_invalid_component_raises(self, mcp_server2: tuple[SEToolsMCPServer, str, str]) -> None: server, left, right = mcp_server2 with pytest.raises(ValueError): - server.setools_diff_policies(left, right, components=["bad_component"]) + server.setools_tool_diff_policies(left, right, components=["bad_component"]) def test_te_rules_component_structure(self, mcp_server2: tuple[SEToolsMCPServer, str, str]) -> None: server, left, right = mcp_server2 - data = assert_payload(server.setools_diff_policies( + data = assert_payload(server.setools_tool_diff_policies( left, right, components=["te_rules"])) te = data["result"]["te_rules"] for key in ("added_allows", "removed_allows", "modified_allows"): @@ -631,7 +634,7 @@ def test_te_rules_component_structure(self, def test_rbac_rules_component(self, mcp_server2: tuple[SEToolsMCPServer, str, str]) -> None: server, left, right = mcp_server2 - data = assert_payload(server.setools_diff_policies( + data = assert_payload(server.setools_tool_diff_policies( left, right, components=["rbac_rules"])) rbac = data["result"]["rbac_rules"] for key in ("added_role_allows", "removed_role_allows", @@ -641,7 +644,7 @@ def test_rbac_rules_component(self, def test_mls_rules_component(self, mcp_server2: tuple[SEToolsMCPServer, str, str]) -> None: server, left, right = mcp_server2 - data = assert_payload(server.setools_diff_policies( + data = assert_payload(server.setools_tool_diff_policies( left, right, components=["mls_rules"])) mls = data["result"]["mls_rules"] for key in ("added_range_transitions", "removed_range_transitions"): @@ -650,8 +653,43 @@ def test_mls_rules_component(self, def test_portcons_component(self, mcp_server2: tuple[SEToolsMCPServer, str, str]) -> None: server, left, right = mcp_server2 - data = assert_payload(server.setools_diff_policies( + data = assert_payload(server.setools_tool_diff_policies( left, right, components=["portcons"])) pc = data["result"]["portcons"] for key in ("added", "removed"): assert key in pc + + +@pytest.mark.obj_args(SELINUX_POLICY) +class TestLookupFileContext: + def test_lookup_default_filetype(self, mcp_server: SEToolsMCPServer) -> None: + data = assert_payload(mcp_server.setools_tool_lookup_file_context( + path="/var/run/test7", fc_path=FILE_CONTEXTS)) + assert data["count"] == 1 + assert data["result"] == "user0:object_r:type7:s4:c5" + + def test_lookup_file_filetype(self, mcp_server: SEToolsMCPServer) -> None: + data = assert_payload(mcp_server.setools_tool_lookup_file_context( + path="/usr/bin/test0", filetype="file", fc_path=FILE_CONTEXTS)) + assert data["result"] == "user0:object_r:type0:s4:c5" + + def test_lookup_dir_filetype(self, mcp_server: SEToolsMCPServer) -> None: + data = assert_payload(mcp_server.setools_tool_lookup_file_context( + path="/usr/lib/test1", filetype="dir", fc_path=FILE_CONTEXTS)) + assert data["result"] == "user0:object_r:type1:s4:c5" + + def test_lookup_regex_match(self, mcp_server: SEToolsMCPServer) -> None: + data = assert_payload(mcp_server.setools_tool_lookup_file_context( + path="/opt/test_regex/subdir/file", fc_path=FILE_CONTEXTS)) + assert data["result"] == "user0:object_r:type8:s4:c5" + + def test_lookup_no_match_raises(self, mcp_server: SEToolsMCPServer) -> None: + from setools.exception import NoFileContextsMatch + with pytest.raises(NoFileContextsMatch): + mcp_server.setools_tool_lookup_file_context( + path="/nonexistent/path/that/does/not/match", fc_path=FILE_CONTEXTS) + + def test_lookup_nonexistent_fc_path_raises(self, mcp_server: SEToolsMCPServer) -> None: + with pytest.raises(OSError): + mcp_server.setools_tool_lookup_file_context( + path="/some/path", fc_path="/nonexistent/file_contexts") diff --git a/tests/library/policyrep/file_contexts b/tests/library/policyrep/file_contexts new file mode 100644 index 00000000..74672cc4 --- /dev/null +++ b/tests/library/policyrep/file_contexts @@ -0,0 +1,10 @@ +/usr/bin/test0 -- user0:object_r:type0:s4:c5 +/usr/lib/test1 -d user0:object_r:type1:s4:c5 +/usr/sbin/test2 -c user0:object_r:type2:s4:c5 +/usr/share/test3 -b user0:object_r:type3:s4:c5 +/usr/local/test4 -s user0:object_r:type4:s4:c5 +/usr/local/test5 -p user0:object_r:type5:s4:c5 +/usr/local/test6 -l user0:object_r:type6:s4:c5 +/var/run/test7 user0:object_r:type7:s4:c5 +/opt/test_regex(/.*)? user0:object_r:type8:s4:c5 +/tmp/no_match <> diff --git a/tests/library/policyrep/test_filecontexts.py b/tests/library/policyrep/test_filecontexts.py new file mode 100644 index 00000000..d809e319 --- /dev/null +++ b/tests/library/policyrep/test_filecontexts.py @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: GPL-2.0-only +# +import pytest +import setools + + +@pytest.mark.obj_args("tests/library/policyrep/selinuxpolicy.conf") +class TestFileContexts: + + """Tests for FileContexts class.""" + + def test_lookup_any(self, compiled_policy): + """FileContexts: lookup with default (any) filetype.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/var/run/test7") + assert "user0:object_r:type7:s4:c5" == str(ctx) + + def test_lookup_file(self, compiled_policy): + """FileContexts: lookup with regular file type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/bin/test0", setools.FileContextsFiletype.file) + assert "user0:object_r:type0:s4:c5" == str(ctx) + + def test_lookup_dir(self, compiled_policy): + """FileContexts: lookup with directory type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/lib/test1", setools.FileContextsFiletype.dir) + assert "user0:object_r:type1:s4:c5" == str(ctx) + + def test_lookup_chr_file(self, compiled_policy): + """FileContexts: lookup with character device type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/sbin/test2", setools.FileContextsFiletype.chr_file) + assert "user0:object_r:type2:s4:c5" == str(ctx) + + def test_lookup_blk_file(self, compiled_policy): + """FileContexts: lookup with block device type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/share/test3", setools.FileContextsFiletype.blk_file) + assert "user0:object_r:type3:s4:c5" == str(ctx) + + def test_lookup_sock_file(self, compiled_policy): + """FileContexts: lookup with socket type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/local/test4", setools.FileContextsFiletype.sock_file) + assert "user0:object_r:type4:s4:c5" == str(ctx) + + def test_lookup_fifo_file(self, compiled_policy): + """FileContexts: lookup with named pipe type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/local/test5", setools.FileContextsFiletype.fifo_file) + assert "user0:object_r:type5:s4:c5" == str(ctx) + + def test_lookup_lnk_file(self, compiled_policy): + """FileContexts: lookup with symbolic link type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/local/test6", setools.FileContextsFiletype.lnk_file) + assert "user0:object_r:type6:s4:c5" == str(ctx) + + def test_lookup_regex(self, compiled_policy): + """FileContexts: lookup with regex path.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/opt/test_regex/subdir/file") + assert "user0:object_r:type8:s4:c5" == str(ctx) + + def test_lookup_no_match(self, compiled_policy): + """FileContexts: lookup raises NoFileContextsMatch for unmatched path.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + with pytest.raises(setools.exception.NoFileContextsMatch): + fc.lookup("/nonexistent/path/that/does/not/match") + + def test_path_attribute(self, compiled_policy): + """FileContexts: path attribute is set correctly.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + assert fc.path == "tests/library/policyrep/file_contexts" + + def test_policy_attribute(self, compiled_policy): + """FileContexts: policy attribute is set correctly.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + assert fc.policy is compiled_policy + + def test_context_user(self, compiled_policy): + """FileContexts: returned context has correct user.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/var/run/test7") + assert isinstance(ctx, setools.Context) + assert "user0" == str(ctx.user) + + def test_context_role(self, compiled_policy): + """FileContexts: returned context has correct role.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/var/run/test7") + assert isinstance(ctx, setools.Context) + assert "object_r" == str(ctx.role) + + def test_context_type(self, compiled_policy): + """FileContexts: returned context has correct type.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/var/run/test7") + assert isinstance(ctx, setools.Context) + assert "type7" == str(ctx.type_) + + def test_context_range(self, compiled_policy): + """FileContexts: returned context has correct MLS range.""" + fc = setools.FileContexts(compiled_policy, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/var/run/test7") + assert isinstance(ctx, setools.Context) + assert "s4:c5" == str(ctx.range_) + + def test_open_nonexistent_file(self, compiled_policy): + """FileContexts: OSError raised for nonexistent file_contexts path.""" + with pytest.raises(OSError): + setools.FileContexts(compiled_policy, "/nonexistent/path/file_contexts") + + +class TestFileContextsNoPolicy: + + """Tests for FileContexts when no policy is provided.""" + + def test_attribute(self): + """FileContexts: policy attribute is None when no policy provided.""" + fc = setools.FileContexts(None, "tests/library/policyrep/file_contexts") + assert fc.policy is None + + def test_lookup(self): + """FileContexts: lookup returns raw string when policy is None.""" + fc = setools.FileContexts(None, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/var/run/test7") + assert ctx == "user0:object_r:type7:s4:c5" + assert isinstance(ctx, str) + + def test_lookup_file(self): + """FileContexts: lookup with filetype returns raw string when policy is None.""" + fc = setools.FileContexts(None, "tests/library/policyrep/file_contexts") + ctx = fc.lookup("/usr/bin/test0", setools.FileContextsFiletype.file) + assert ctx == "user0:object_r:type0:s4:c5" + assert isinstance(ctx, str) + + def test_lookup_no_match(self): + """FileContexts: lookup raises NoFileContextsMatch when policy is None.""" + fc = setools.FileContexts(None, "tests/library/policyrep/file_contexts") + with pytest.raises(setools.exception.NoFileContextsMatch): + fc.lookup("/nonexistent/path/that/does/not/match") diff --git a/tests/library/policyrep/test_mls.py b/tests/library/policyrep/test_mls.py index 5cedc8d9..970fd9ab 100644 --- a/tests/library/policyrep/test_mls.py +++ b/tests/library/policyrep/test_mls.py @@ -368,6 +368,141 @@ def test_contains(self, compiled_policy: setools.SELinuxPolicy) -> None: level5 = compiled_policy.lookup_level("s2:c0.c11") assert not (level5 in rangeobj) + def test_dom(self, compiled_policy: setools.SELinuxPolicy) -> None: + """Range dominate (ge).""" + # equal + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert range1 >= range2 + + # dominates (wider low) + range1 = compiled_policy.lookup_range("s0-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert range1 >= range2 + + # dominates (wider high) + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c11") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert range1 >= range2 + + # dominated by + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0-s2:c0.c11") + assert not (range1 >= range2) + + # incomp + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s1:c5-s2:c0.c11") + assert not (range1 >= range2) + + def test_domby(self, compiled_policy: setools.SELinuxPolicy) -> None: + """Range dominated-by (le).""" + # equal + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert range1 <= range2 + + # dominated by (narrower) + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0-s2:c0.c11") + assert range1 <= range2 + + # dominates (wider) + range1 = compiled_policy.lookup_range("s0-s2:c0.c11") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert not (range1 <= range2) + + # incomp + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s1:c5-s2:c0.c11") + assert not (range1 <= range2) + + def test_proper_dom(self, compiled_policy: setools.SELinuxPolicy) -> None: + """Range proper dominate (gt).""" + # equal + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert not (range1 > range2) + + # strictly dominates (wider low) + range1 = compiled_policy.lookup_range("s0-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert range1 > range2 + + # strictly dominates (wider high) + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c11") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert range1 > range2 + + # strictly dominates (wider both) + range1 = compiled_policy.lookup_range("s0-s2:c0.c11") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert range1 > range2 + + # dominated by + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0-s2:c0.c11") + assert not (range1 > range2) + + # incomp + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s1:c5-s2:c0.c11") + assert not (range1 > range2) + + def test_proper_domby(self, compiled_policy: setools.SELinuxPolicy) -> None: + """Range proper dominated-by (lt).""" + # equal + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert not (range1 < range2) + + # strictly dominated by (narrower low) + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0-s2:c0.c10") + assert range1 < range2 + + # strictly dominated by (narrower high) + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c11") + assert range1 < range2 + + # strictly dominated by (narrower both) + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0-s2:c0.c11") + assert range1 < range2 + + # dominates + range1 = compiled_policy.lookup_range("s0-s2:c0.c11") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert not (range1 < range2) + + # incomp + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s1:c5-s2:c0.c11") + assert not (range1 < range2) + + def test_incomp(self, compiled_policy: setools.SELinuxPolicy) -> None: + """Range incomparable (xor).""" + # equal + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert not (range1 ^ range2) + + # dominates + range1 = compiled_policy.lookup_range("s0-s2:c0.c11") + range2 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + assert not (range1 ^ range2) + + # dominated by + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s0-s2:c0.c11") + assert not (range1 ^ range2) + + # incomp (overlapping but neither contains the other) + range1 = compiled_policy.lookup_range("s0:c1-s2:c0.c10") + range2 = compiled_policy.lookup_range("s1:c5-s2:c0.c11") + assert range1 ^ range2 + def test_range_statement(self, compiled_policy: setools.SELinuxPolicy) -> None: """Range has no statement.""" rangeobj = compiled_policy.lookup_range("s0") diff --git a/tox.ini b/tox.ini index 5d6ac622..5fc20064 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ deps = {[testenv]deps} commands_pre = coverage --version coverage erase {[testenv]commands_pre} -commands = coverage run --source=setools,setoolsgui -m pytest tests +commands = coverage run --source=setools,setoolsgui -m pytest {posargs} tests coverage report [testenv:lint] @@ -50,7 +50,7 @@ passenv = USERSPACE_SRC XAUTHORITY deps = networkx>=2.6 cython>=0.29.14 - mcp>=1.0 + fastmcp>=2.0 pytest>=6.0 PyQt6>=6.0 pygraphviz @@ -58,4 +58,4 @@ deps = networkx>=2.6 pytest-xvfb setuptools;python_version>="3.12" commands_pre = {envpython} setup.py build_ext -i -commands = {envpython} -m pytest tests +commands = {envpython} -m pytest {posargs} tests