Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,25 @@
# `allow`, `deny`
# PLUGINS_DEFAULT_HOOK_POLICY=allow

# Path to plugins folder
# PLUGINS_FOLDER=plugins
# Path to main plugins configuration file
# PLUGINS_CONFIG_FILE=plugins/config.yaml

### Plugin installation
# Comma Separated Values used by install with --type monorepo
# PLUGINS_REPO_URLS="https://github.com/ibm/cpex-plugins"

# registry path
# PLUGIN_REGISTRY_FOLDER=data

# Github API
# PLUGINS_GITHUB_API=api.github.com

# PLUGINS_GITHUB_TOKEN=<github token>
### end Plugin installation


# Logging level for plugin framework components
# PLUGINS_LOG_LEVEL=INFO

Expand Down Expand Up @@ -148,3 +164,15 @@
# PLUGINS_GRPC_SERVER_SSL_ENABLED=



### Package Integrity Verification
# Enable SHA256 hash verification for PyPI packages (default: True)
# When enabled, downloaded packages are verified against hashes from PyPI's JSON API
# Recommended: Keep enabled for security
# PLUGINS_VERIFY_PACKAGE_INTEGRITY=True

# Strict integrity mode (default: False)
# When True: Fail installation if package hashes are unavailable
# When False: Warn but continue if hashes are unavailable
# Recommended: False for development, True for production
# PLUGINS_STRICT_INTEGRITY_MODE=False
29 changes: 18 additions & 11 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ token.txt
cpex.sbom.xml
docs/docs/test/
docs/resources/
tmp
*.tgz
*.gz
*.bz
Expand All @@ -48,8 +47,10 @@ node_modules/
mcp.db-journal
mcp.db-shm
mcp.db-wal
certs/
jwt/
# Anchored: matches only ./certs/ at the repo root, not nested
# `certs/` directories under source. Bare `certs/` would silently
# hide any cert-management module / test fixture at any depth.
/certs/
FIXMEs
*.old
logs/
Expand All @@ -62,19 +63,22 @@ corpus/
tests/fuzz/fuzzers/results/
.venv
mcp.db
public/
# Anchored to repo root — bare `public/` would shadow any nested
# `public/` directory in source (common in web/frontend code).
/public/
ica_integrations_host.sbom.json
.pyre
dictionary.dic
pdm.lock
.pdm-python
temp/
public/
*history.md
htmlcov
test_commands.md
cover.md
build/
# Anchored: bare `build/` would shadow any nested build-output dir
# anywhere in the source tree.
/build/
.icaenv
commands_output.txt
commands_output.md
Expand All @@ -94,7 +98,6 @@ scribeflow.log
coverage_re
bin/flagged
flagged/
certs/
# VENV
.python37/
.python39/
Expand All @@ -111,16 +114,20 @@ __pycache__/
# C extensions
*.so

# Distribution / packaging
# Distribution / packaging — Python build artifacts. `build/` and
# `lib/` are anchored (root-only) so they don't silently hide
# nested source dirs of the same name. Other patterns (`dist/`,
# `downloads/`, `eggs/`, …) stay bare — they're less likely to
# collide with source-tree directory names.
.wily/
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
# Anchored: bare `lib/` would shadow any nested `lib/` source dir.
/lib/
lib64/
parts/
sdist/
Expand Down Expand Up @@ -250,4 +257,4 @@ db_path/
tmp/

.continue

plugin-catalog
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).

- Initial release

[Unreleased]: https://github.com/contextforge-org/contextforge-plugins-framework/compare/0.1.0...HEAD
[0.1.0]: https://github.com/contextforge-org/contextforge-plugins-framework/releases/tag/0.1.0
[Unreleased]: https://github.com/contextforge-org/cpex/compare/0.1.0...HEAD
[0.1.0]: https://github.com/contextforge-org/cpex/releases/tag/0.1.0
10 changes: 5 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
Our project welcomes external contributions. If you have an itch, please feel
free to scratch it.

To contribute code or documentation, please submit a [pull request](https://github.com/contextforge-org/contextforge-plugins-framework/pulls).
To contribute code or documentation, please submit a [pull request](https://github.com/contextforge-org/cpex/pulls).

A good way to familiarize yourself with the codebase and contribution process is
to look for and tackle low-hanging fruit in the [issue tracker](https://github.com/contextforge-org/contextforge-plugins-framework/issues).
to look for and tackle low-hanging fruit in the [issue tracker](https://github.com/contextforge-org/cpex/issues).
Before embarking on a more ambitious contribution, please quickly [get in touch](#communication) with us.

**Note: We appreciate your effort, and want to avoid a situation where a contribution
Expand All @@ -17,14 +17,14 @@ cannot be accepted at all!**

### Proposing new features

If you would like to implement a new feature, please [raise an issue](https://github.com/contextforge-org/contextforge-plugins-framework/issues)
If you would like to implement a new feature, please [raise an issue](https://github.com/contextforge-org/cpex/issues)
before sending a pull request so the feature can be discussed. This is to avoid
you wasting your valuable time working on a feature that the project developers
are not interested in accepting into the code base.

### Fixing bugs

If you would like to fix a bug, please [raise an issue](https://github.com/contextforge-org/contextforge-plugins-framework/issues) before sending a
If you would like to fix a bug, please [raise an issue](https://github.com/contextforge-org/cpex/issues) before sending a
pull request so it can be tracked.

### Merge approval
Expand Down Expand Up @@ -70,7 +70,7 @@ git commit -s

## Communication

Please feel free to connect with us through the [issue tracker](https://github.com/contextforge-org/contextforge-plugins-framework/issues).
Please feel free to connect with us through the [issue tracker](https://github.com/contextforge-org/cpex/issues).

## Setup

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<div>
<img alt="ContextForge Plugin Extensibility Framework (CPEX) logo" src="https://github.com/contextforge-org/contextforge-plugins-framework/blob/main/docs/images/cpex_v1.png?raw=true" height=100">
<img alt="ContextForge Plugin Extensibility Framework (CPEX) logo" src="https://github.com/contextforge-org/cpex/blob/main/docs/images/cpex_v1.png?raw=true" height=100">
</div>

# CPEX — ContextForge Plugin Extensibility Framework

<i>A composable enforcement framework for AI agents and toolchains.</i>

[![CI](https://github.com/contextforge-org/contextforge-plugins-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/contextforge-org/contextforge-plugins-framework/actions/workflows/ci.yml)
[![CI](https://github.com/contextforge-org/cpex/actions/workflows/ci.yml/badge.svg)](https://github.com/contextforge-org/cpex/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
[![PyPI](https://img.shields.io/pypi/v/cpex.svg?color=blue)](https://pypi.org/project/cpex)

> [**Read the project vision**](https://contextforge-org.github.io/contextforge-plugins-framework/docs/vision/) to learn why hooks, plugins, and policy are the path to agent security.
> [**Read the project vision**](https://contextforge-org.github.io/cpex/docs/vision/) to learn why hooks, plugins, and policy are the path to agent security.

## What's CPEX?

Expand Down
8 changes: 6 additions & 2 deletions cpex/framework/external/grpc/server/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,14 @@ def main() -> None:

args = parser.parse_args()

# Configure logging
# Configure logging - respect PLUGINS_LOG_LEVEL environment variable
settings = get_settings()
log_level_str = settings.log_level or args.log_level
log_level = getattr(logging, log_level_str.upper(), logging.INFO)
logging.basicConfig(
level=getattr(logging, args.log_level),
level=log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stderr,
)

# Run the server
Expand Down
13 changes: 12 additions & 1 deletion cpex/framework/external/mcp/server/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,18 @@
MCP_SERVER_INSTRUCTIONS,
MCP_SERVER_NAME,
)
from cpex.framework.settings import get_transport_settings

# Configure logging - respect PLUGINS_LOG_LEVEL environment variable
from cpex.framework.settings import get_settings, get_transport_settings

settings = get_settings()
log_level_str = settings.log_level
log_level = getattr(logging, log_level_str.upper(), logging.INFO)
logging.basicConfig(
level=log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stderr,
)

logger = logging.getLogger(__name__)

Expand Down
7 changes: 5 additions & 2 deletions cpex/framework/external/unix/server/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@
from cpex.framework.external.unix.server.server import run_server
from cpex.framework.settings import get_settings

# Configure logging
# Configure logging - respect PLUGINS_LOG_LEVEL environment variable
settings = get_settings()
log_level_str = settings.log_level
log_level = getattr(logging, log_level_str.upper(), logging.INFO)
logging.basicConfig(
level=logging.INFO,
level=log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stderr, # Log to stderr to keep stdout clean for coordination
)
Expand Down
38 changes: 21 additions & 17 deletions cpex/framework/isolated/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from cpex.framework.hooks.registry import get_hook_registry
from cpex.framework.isolated.venv_comm import VenvProcessCommunicator
from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult
from cpex.framework.utils import find_package_path

logger = logging.getLogger(__name__)

Expand All @@ -43,10 +44,10 @@ def __init__(self, config: PluginConfig, plugin_dirs) -> None:
# use the first plugin dir specified in the plugin configuration file.
path = Path(self.plugin_dirs[0]).resolve()
class_root = self.config.config.get("class_name").split(".")[0]
cache_root = path / class_root
self.plugin_path = cache_root
cache_root: Path = path / class_root
self.plugin_path: Path = cache_root
if not cache_root.exists():
raise RuntimeError(f"plugin path does not exist: {str(cache_root)}")
cache_root.mkdir(parents=True, exist_ok=True)
self.cache_dir: Path = cache_root / ".cpex" / "venv_cache"
self.cache_dir.mkdir(parents=True, exist_ok=True)

Expand Down Expand Up @@ -217,21 +218,17 @@ async def initialize(self) -> None:
else:
requirements_file = Path(requirements_file_input)

# If it's a relative path, resolve it relative to plugin_path
if not requirements_file.is_absolute():
requirements_file = (self.plugin_path / requirements_file).resolve()
else:
# If absolute, resolve it to normalize
requirements_file = requirements_file.resolve()

# Validate that the resolved path is within plugin_path (security check)
# Try to find the package location where plugin-manifest.yaml resides
# Fall back to self.plugin_path if package is not installed (e.g., in tests)
try:
requirements_file.relative_to(self.plugin_path.resolve())
except ValueError as ve:
raise RuntimeError(
f"Invalid requirements_file path: {requirements_file_input}. "
f"Path must be within plugin directory: {self.plugin_path}"
) from ve
package_path = find_package_path(self.config.name)
logger.debug("Found installed package %s at %s", self.config.name, package_path)
except RuntimeError:
# Package not installed (e.g., in test environment), use plugin_path
package_path = self.plugin_path
logger.debug("Package %s not installed, using plugin_path: %s", self.config.name, package_path)

requirements_file = package_path / requirements_file_input

# Create venv with caching support
new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True)
Expand Down Expand Up @@ -339,3 +336,10 @@ async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: Plu
except Exception as e:
logger.exception("Unexpected error invoking hook '%s' for plugin '%s'", hook_type, self.name)
raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e

def remove_venv(self):
"""
Remove the virtual environment associated with the plugin.
"""
shutil.rmtree(self.plugin_path.joinpath(".cpex"))
shutil.rmtree(self.plugin_path.joinpath(".venv"))
8 changes: 8 additions & 0 deletions cpex/framework/isolated/venv_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ def _get_python_executable(self):

return str(python_exe)

def upgrade_pip(self) -> None:
"""Upgrade pip in the target venv."""
try:
subprocess.check_call([self.python_executable, "-m", "pip", "install", "--upgrade", "pip"])
except Exception as e:
raise RuntimeError("Failed to upgrade pip") from e

def install_requirements(self, requirements_file: str) -> None:
"""
Install Python requirements from a file in the target venv.
Expand All @@ -62,6 +69,7 @@ def install_requirements(self, requirements_file: str) -> None:
requirements_path = Path(requirements_file)
if requirements_path.exists():
try:
self.upgrade_pip()
subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file])
except Exception as e:
raise RuntimeError(f"Failed to install requirements from {requirements_file}") from e
Expand Down
25 changes: 14 additions & 11 deletions cpex/framework/isolated/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS
from cpex.framework.manager import PluginExecutor
from cpex.framework.models import PluginConfig, PluginContext
from cpex.framework.utils import parse_class_name
from cpex.framework.utils import import_module, parse_class_name

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -115,7 +115,7 @@ async def process_task(task_data, tp: TaskProcessor):
hook_type = task_data.get(HOOK_TYPE)
cls_name: str = task_data.get("class_name")
mod_name, n_cls_name = parse_class_name(cls_name)
module: ModuleType = importlib.import_module(mod_name)
module: ModuleType = import_module(mod_name)
# cool, we found the module, and verified it implemented the hook type.
class_ = getattr(module, n_cls_name)
plugin_type = cast(Type[Plugin], class_)
Expand Down Expand Up @@ -162,7 +162,7 @@ async def main():
while True:
try:
# Read one line at a time
if tp.plugin_config:
if tp.plugin_config and "max_content_size" in tp.plugin_config:
line = sys.stdin.readline(limit=int(tp.plugin_config.max_content_size))
else:
# on the first read, the plugin_config has not yet been initialized so just read.
Expand Down Expand Up @@ -198,14 +198,17 @@ async def main():
serialized_response = json.dumps(serializable_response)
# Send response back to parent (one line per response)
if tp.plugin_config:
if len(serialized_response) > tp.plugin_config.max_content_size:
logger.error("Serialized response exceeds max content size")
error_response = {
"status": "error",
"message": "Serialized response exceeds max content size",
"request_id": request_id,
}
serialized_response = json.dumps(error_response)
# workaround until cpex is updated beyond dev11
# cpex is a dependency of the plugin and as such it's PluginConfig does not contain the max_content_size yet.
if "max_content_size" in tp.plugin_config:
if len(serialized_response) > tp.plugin_config.max_content_size:
logger.error("Serialized response exceeds max content size")
error_response = {
"status": "error",
"message": "Serialized response exceeds max content size",
"request_id": request_id,
}
serialized_response = json.dumps(error_response)
print(serialized_response, flush=True)

except json.JSONDecodeError as e:
Expand Down
Loading